elementor-mcp-agent 0.1.0 → 1.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.
package/dist/server.js CHANGED
@@ -1,43 +1,17 @@
1
1
  #!/usr/bin/env node
2
-
3
- // src/server.ts
4
- import { readFileSync as readFileSync3 } from "fs";
5
- import { dirname as dirname2, resolve as resolve2 } from "path";
6
- import { fileURLToPath as fileURLToPath2 } from "url";
7
- import { Server } from "@modelcontextprotocol/sdk/server/index.js";
8
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9
- import {
10
- CallToolRequestSchema,
11
- ListToolsRequestSchema,
12
- ListResourcesRequestSchema,
13
- ReadResourceRequestSchema
14
- } from "@modelcontextprotocol/sdk/types.js";
15
- import { zodToJsonSchema } from "zod-to-json-schema";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __esm = (fn, res) => function __init() {
5
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
6
+ };
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
16
11
 
17
12
  // src/config.ts
18
13
  import { readFileSync } from "fs";
19
14
  import { z } from "zod";
20
- var SiteSchema = z.object({
21
- id: z.string().min(1, "site id required"),
22
- url: z.string().url("invalid site url"),
23
- username: z.string().min(1),
24
- application_password: z.string().min(20, "WP application password should be ~24 chars"),
25
- ssh: z.object({
26
- host: z.string(),
27
- user: z.string(),
28
- port: z.coerce.number().int().min(1).max(65535).default(22),
29
- path: z.string().describe("WP root path on remote, e.g. ~/sites/example.com"),
30
- key_path: z.string().optional().describe("absolute path to private key")
31
- }).optional()
32
- });
33
- var ConfigSchema = z.object({
34
- sites: z.array(SiteSchema).min(1, "at least one site is required"),
35
- default_site_id: z.string().optional(),
36
- rate_limit_per_minute: z.coerce.number().int().min(1).max(600).default(60),
37
- confirmation_ttl_seconds: z.coerce.number().int().min(10).max(600).default(60),
38
- log_level: z.enum(["trace", "debug", "info", "warn", "error", "fatal", "silent"]).default("info")
39
- });
40
- var cached = null;
41
15
  function loadConfig() {
42
16
  if (cached) return cached;
43
17
  let raw;
@@ -111,47 +85,36 @@ function getSite(siteId) {
111
85
  }
112
86
  return s;
113
87
  }
114
-
115
- // src/tools/sites.ts
116
- import { z as z2 } from "zod";
117
-
118
- // src/types/tool.ts
119
- function defineTool(def) {
120
- return def;
121
- }
88
+ var SiteSchema, ConfigSchema, cached;
89
+ var init_config = __esm({
90
+ "src/config.ts"() {
91
+ "use strict";
92
+ SiteSchema = z.object({
93
+ id: z.string().min(1, "site id required"),
94
+ url: z.string().url("invalid site url"),
95
+ username: z.string().min(1),
96
+ application_password: z.string().min(20, "WP application password should be ~24 chars"),
97
+ ssh: z.object({
98
+ host: z.string(),
99
+ user: z.string(),
100
+ port: z.coerce.number().int().min(1).max(65535).default(22),
101
+ path: z.string().describe("WP root path on remote, e.g. ~/sites/example.com"),
102
+ key_path: z.string().optional().describe("absolute path to private key"),
103
+ wp_cli_path: z.string().optional().describe("Explicit wp-cli invocation prefix. Examples: 'wp' (default, if wp is in PATH), 'php ~/bin/wp.phar', '/usr/local/bin/wp'. Auto-detected if omitted.")
104
+ }).optional()
105
+ });
106
+ ConfigSchema = z.object({
107
+ sites: z.array(SiteSchema).min(1, "at least one site is required"),
108
+ default_site_id: z.string().optional(),
109
+ rate_limit_per_minute: z.coerce.number().int().min(1).max(600).default(60),
110
+ confirmation_ttl_seconds: z.coerce.number().int().min(10).max(600).default(60),
111
+ log_level: z.enum(["trace", "debug", "info", "warn", "error", "fatal", "silent"]).default("info")
112
+ });
113
+ cached = null;
114
+ }
115
+ });
122
116
 
123
117
  // src/throttle/token-bucket.ts
124
- var TokenBucket = class {
125
- constructor(capacityPerMinute) {
126
- this.capacityPerMinute = capacityPerMinute;
127
- this.tokens = capacityPerMinute;
128
- this.refillPerMs = capacityPerMinute / 6e4;
129
- }
130
- capacityPerMinute;
131
- tokens;
132
- lastRefill = Date.now();
133
- refillPerMs;
134
- async acquire() {
135
- while (this.tokens < 1) {
136
- this.refill();
137
- if (this.tokens < 1) await new Promise((r) => setTimeout(r, 80));
138
- }
139
- this.tokens -= 1;
140
- }
141
- refill() {
142
- const now = Date.now();
143
- this.tokens = Math.min(
144
- this.capacityPerMinute,
145
- this.tokens + (now - this.lastRefill) * this.refillPerMs
146
- );
147
- this.lastRefill = now;
148
- }
149
- get available() {
150
- this.refill();
151
- return Math.floor(this.tokens);
152
- }
153
- };
154
- var buckets = /* @__PURE__ */ new Map();
155
118
  function bucketFor(siteId, perMinute) {
156
119
  let b = buckets.get(siteId);
157
120
  if (!b) {
@@ -160,20 +123,45 @@ function bucketFor(siteId, perMinute) {
160
123
  }
161
124
  return b;
162
125
  }
126
+ var TokenBucket, buckets;
127
+ var init_token_bucket = __esm({
128
+ "src/throttle/token-bucket.ts"() {
129
+ "use strict";
130
+ TokenBucket = class {
131
+ constructor(capacityPerMinute) {
132
+ this.capacityPerMinute = capacityPerMinute;
133
+ this.tokens = capacityPerMinute;
134
+ this.refillPerMs = capacityPerMinute / 6e4;
135
+ }
136
+ capacityPerMinute;
137
+ tokens;
138
+ lastRefill = Date.now();
139
+ refillPerMs;
140
+ async acquire() {
141
+ while (this.tokens < 1) {
142
+ this.refill();
143
+ if (this.tokens < 1) await new Promise((r) => setTimeout(r, 80));
144
+ }
145
+ this.tokens -= 1;
146
+ }
147
+ refill() {
148
+ const now = Date.now();
149
+ this.tokens = Math.min(
150
+ this.capacityPerMinute,
151
+ this.tokens + (now - this.lastRefill) * this.refillPerMs
152
+ );
153
+ this.lastRefill = now;
154
+ }
155
+ get available() {
156
+ this.refill();
157
+ return Math.floor(this.tokens);
158
+ }
159
+ };
160
+ buckets = /* @__PURE__ */ new Map();
161
+ }
162
+ });
163
163
 
164
164
  // src/api/errors.ts
165
- var WPError = class extends Error {
166
- constructor(kind, code, message, raw) {
167
- super(message);
168
- this.kind = kind;
169
- this.code = code;
170
- this.raw = raw;
171
- this.name = "WPError";
172
- }
173
- kind;
174
- code;
175
- raw;
176
- };
177
165
  function fromHttp(status, body, fallback = "WordPress API error") {
178
166
  const raw = body;
179
167
  const code = raw?.code ?? `http_${status}`;
@@ -186,17 +174,41 @@ function fromHttp(status, body, fallback = "WordPress API error") {
186
174
  else if (status >= 500) kind = "server";
187
175
  return new WPError(kind, code, msg, body);
188
176
  }
177
+ var WPError;
178
+ var init_errors = __esm({
179
+ "src/api/errors.ts"() {
180
+ "use strict";
181
+ WPError = class extends Error {
182
+ constructor(kind, code, message, raw) {
183
+ super(message);
184
+ this.kind = kind;
185
+ this.code = code;
186
+ this.raw = raw;
187
+ this.name = "WPError";
188
+ }
189
+ kind;
190
+ code;
191
+ raw;
192
+ };
193
+ }
194
+ });
189
195
 
190
196
  // src/utils/logger.ts
191
197
  import pino from "pino";
192
- var logger = pino(
193
- {
194
- level: process.env.LOG_LEVEL ?? "info",
195
- base: { name: "elementor-mcp-agent" }
196
- },
197
- // Critical: stdout is reserved for MCP JSON-RPC. All logs MUST go to stderr.
198
- pino.destination(2)
199
- );
198
+ var logger;
199
+ var init_logger = __esm({
200
+ "src/utils/logger.ts"() {
201
+ "use strict";
202
+ logger = pino(
203
+ {
204
+ level: process.env.LOG_LEVEL ?? "info",
205
+ base: { name: "elementor-mcp-agent" }
206
+ },
207
+ // Critical: stdout is reserved for MCP JSON-RPC. All logs MUST go to stderr.
208
+ pino.destination(2)
209
+ );
210
+ }
211
+ });
200
212
 
201
213
  // src/api/wp-rest.ts
202
214
  function authHeader(site) {
@@ -245,23 +257,443 @@ async function wpRequest(path, opts = {}) {
245
257
  }
246
258
  return parsed;
247
259
  }
260
+ var init_wp_rest = __esm({
261
+ "src/api/wp-rest.ts"() {
262
+ "use strict";
263
+ init_config();
264
+ init_token_bucket();
265
+ init_errors();
266
+ init_logger();
267
+ }
268
+ });
269
+
270
+ // src/transport/ssh-wpcli.ts
271
+ import { spawn } from "child_process";
272
+ async function detectWpCliPath(site, sshOpts) {
273
+ if (site.ssh?.wp_cli_path) return site.ssh.wp_cli_path;
274
+ const cached2 = wpCliPathCache.get(site.id);
275
+ if (cached2) return cached2;
276
+ const probe = `command -v wp 2>/dev/null && echo wp || (test -f "$HOME/bin/wp.phar" && echo "php $HOME/bin/wp.phar") || (test -f "$HOME/wp-cli.phar" && echo "php $HOME/wp-cli.phar") || echo NONE`;
277
+ const { spawn: spawn3 } = await import("child_process");
278
+ const result = await new Promise((resolve3) => {
279
+ const child = spawn3("ssh", [...sshOpts, `${site.ssh.user}@${site.ssh.host}`, probe], { stdio: ["ignore", "pipe", "pipe"] });
280
+ let out = "";
281
+ child.stdout.on("data", (b) => {
282
+ out += b.toString();
283
+ });
284
+ child.on("close", () => resolve3(out.trim().split("\n").pop() ?? "NONE"));
285
+ });
286
+ const detected = result === "NONE" ? "wp" : result;
287
+ wpCliPathCache.set(site.id, detected);
288
+ return detected;
289
+ }
290
+ async function sshWpCli(site, wpArgs, opts = {}) {
291
+ if (!site.ssh) {
292
+ throw new Error(
293
+ `Site '${site.id}' has no SSH configuration. WP-CLI tools require SSH access. Add an "ssh" object to the site config with at least {host, user, path}.`
294
+ );
295
+ }
296
+ const { host, user, port, path: wpPath, key_path } = site.ssh;
297
+ const timeout = opts.timeout_ms ?? 6e4;
298
+ const sshArgs = [
299
+ "-o",
300
+ "StrictHostKeyChecking=no",
301
+ "-o",
302
+ "BatchMode=yes",
303
+ "-o",
304
+ `ConnectTimeout=${Math.min(15, Math.floor(timeout / 1e3))}`,
305
+ "-p",
306
+ String(port ?? 22)
307
+ ];
308
+ if (key_path) sshArgs.push("-i", key_path);
309
+ const wpCmd = await detectWpCliPath(site, sshArgs);
310
+ sshArgs.push(`${user}@${host}`);
311
+ const remoteCmd = `${wpCmd} --path=${shellEscape(wpPath)} ${wpArgs}`;
312
+ sshArgs.push(remoteCmd);
313
+ logger.debug({ site_id: site.id, cmd: remoteCmd }, "ssh wp-cli");
314
+ const t0 = Date.now();
315
+ return new Promise((resolve3, reject) => {
316
+ const child = spawn("ssh", sshArgs, { stdio: ["ignore", "pipe", "pipe"] });
317
+ let stdout = "";
318
+ let stderr = "";
319
+ const killer = setTimeout(() => {
320
+ child.kill("SIGKILL");
321
+ reject(new Error(`SSH wp-cli timed out after ${timeout}ms: ${remoteCmd}`));
322
+ }, timeout);
323
+ child.stdout.on("data", (b) => {
324
+ stdout += b.toString();
325
+ });
326
+ child.stderr.on("data", (b) => {
327
+ stderr += b.toString();
328
+ });
329
+ child.on("error", (e) => {
330
+ clearTimeout(killer);
331
+ reject(e);
332
+ });
333
+ child.on("close", (code) => {
334
+ clearTimeout(killer);
335
+ const duration_ms = Date.now() - t0;
336
+ const cleanedStderr = stderr.split("\n").filter((l) => !l.includes("post-quantum") && !l.includes("openssh.com/pq") && !l.includes("decrypt later") && !l.includes("This session may be") && !l.includes("server may need to be")).join("\n").trim();
337
+ resolve3({
338
+ stdout: stdout.trim(),
339
+ stderr: cleanedStderr,
340
+ exitCode: code ?? -1,
341
+ duration_ms,
342
+ command: remoteCmd
343
+ });
344
+ });
345
+ });
346
+ }
347
+ function shellEscape(s) {
348
+ return `'${s.replace(/'/g, "'\\''")}'`;
349
+ }
350
+ function isDestructiveWpCli(args) {
351
+ return DESTRUCTIVE_WPCLI_PATTERNS.some((p) => p.test(args));
352
+ }
353
+ var wpCliPathCache, DESTRUCTIVE_WPCLI_PATTERNS;
354
+ var init_ssh_wpcli = __esm({
355
+ "src/transport/ssh-wpcli.ts"() {
356
+ "use strict";
357
+ init_logger();
358
+ wpCliPathCache = /* @__PURE__ */ new Map();
359
+ DESTRUCTIVE_WPCLI_PATTERNS = [
360
+ /\bdelete-all\b/i,
361
+ /\bdelete\b(?!.*--dry-run)/i,
362
+ /\bdrop\b/i,
363
+ /\bdb\s+(reset|drop)\b/i,
364
+ /\bpost\s+delete\b/i,
365
+ /\boption\s+delete\b/i,
366
+ /\bplugin\s+(deactivate|uninstall)\b/i,
367
+ /\bsearch-replace\b(?!.*--dry-run)/i,
368
+ /\buser\s+delete\b/i
369
+ ];
370
+ }
371
+ });
372
+
373
+ // src/elementor/backup.ts
374
+ var backup_exports = {};
375
+ __export(backup_exports, {
376
+ fullBackup: () => fullBackup,
377
+ listBackups: () => listBackups,
378
+ restoreBackup: () => restoreBackup,
379
+ restoreFromFile: () => restoreFromFile
380
+ });
381
+ import { writeFileSync, mkdirSync, existsSync, readFileSync as readFileSync2 } from "fs";
382
+ import { tmpdir } from "os";
383
+ import { join } from "path";
384
+ async function fullBackup(siteId, postId, opts = {}) {
385
+ const current = await wpRequest(
386
+ `/wp/v2/pages/${postId}?context=edit&_fields=meta,title`,
387
+ { siteId }
388
+ );
389
+ const data_raw_v = current.meta?._elementor_data;
390
+ const data_raw = typeof data_raw_v === "string" ? data_raw_v : JSON.stringify(data_raw_v ?? []);
391
+ const settings_raw_v = current.meta?._elementor_page_settings;
392
+ const settings_raw = typeof settings_raw_v === "string" ? settings_raw_v : JSON.stringify(settings_raw_v ?? {});
393
+ const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
394
+ const meta_key = `_elementor_data_backup_${ts}`;
395
+ const page_settings_meta_key = `_elementor_page_settings_backup_${ts}`;
396
+ const size_bytes = data_raw.length + settings_raw.length;
397
+ const site = getSite(siteId);
398
+ let method = "file";
399
+ if (site.ssh && !opts.force_file_only) {
400
+ try {
401
+ const setDataCmd = `post meta update ${postId} ${meta_key} ${shellQuote(data_raw)}`;
402
+ const setSettingsCmd = `post meta update ${postId} ${page_settings_meta_key} ${shellQuote(settings_raw)}`;
403
+ const r1 = await sshWpCli(site, setDataCmd, { timeout_ms: 3e4 });
404
+ if (r1.exitCode !== 0) throw new Error(`wp-cli postmeta data set failed: ${r1.stderr}`);
405
+ const r2 = await sshWpCli(site, setSettingsCmd, { timeout_ms: 3e4 });
406
+ if (r2.exitCode !== 0) throw new Error(`wp-cli postmeta settings set failed: ${r2.stderr}`);
407
+ method = "wp-cli";
408
+ } catch (e) {
409
+ logger.warn({ err: e.message }, "WP-CLI backup failed, falling back to file");
410
+ }
411
+ }
412
+ let file_path;
413
+ if (opts.to_file || method !== "wp-cli") {
414
+ if (!existsSync(BACKUP_DIR)) mkdirSync(BACKUP_DIR, { recursive: true });
415
+ const safeSiteId = (siteId ?? "default").replace(/[^a-zA-Z0-9_-]/g, "_");
416
+ const filename = `${safeSiteId}_page${postId}_${ts}.json`;
417
+ file_path = join(BACKUP_DIR, filename);
418
+ writeFileSync(file_path, JSON.stringify({
419
+ site_id: siteId,
420
+ page_id: postId,
421
+ title: current.title.rendered,
422
+ timestamp: ts,
423
+ _elementor_data: data_raw,
424
+ _elementor_page_settings: settings_raw
425
+ }, null, 2));
426
+ if (method !== "wp-cli") method = "file";
427
+ logger.info({ file_path }, "backup written to file");
428
+ }
429
+ return {
430
+ meta_key,
431
+ page_settings_meta_key,
432
+ file_path,
433
+ size_bytes,
434
+ method
435
+ };
436
+ }
437
+ async function restoreBackup(siteId, postId, data_meta_key, settings_meta_key) {
438
+ const site = getSite(siteId);
439
+ if (!site.ssh) {
440
+ throw new Error("Restoring from postmeta backup requires SSH (WP-CLI). For file backups, use restoreFromFile.");
441
+ }
442
+ const r1 = await sshWpCli(site, `post meta get ${postId} ${data_meta_key}`, { timeout_ms: 3e4 });
443
+ if (r1.exitCode !== 0) throw new Error(`Backup '${data_meta_key}' not found: ${r1.stderr}`);
444
+ const data_value = r1.stdout;
445
+ if (!data_value) throw new Error(`Backup '${data_meta_key}' is empty`);
446
+ let settings_value;
447
+ if (settings_meta_key) {
448
+ const r2 = await sshWpCli(site, `post meta get ${postId} ${settings_meta_key}`, { timeout_ms: 3e4 });
449
+ if (r2.exitCode === 0 && r2.stdout) settings_value = r2.stdout;
450
+ }
451
+ const writeData = await sshWpCli(site, `post meta update ${postId} _elementor_data ${shellQuote(data_value)}`, { timeout_ms: 3e4 });
452
+ if (writeData.exitCode !== 0) throw new Error(`Restore write failed: ${writeData.stderr}`);
453
+ if (settings_value !== void 0) {
454
+ await sshWpCli(site, `post meta update ${postId} _elementor_page_settings ${shellQuote(settings_value)}`, { timeout_ms: 3e4 });
455
+ }
456
+ return { restored: true, method: "wp-cli" };
457
+ }
458
+ async function listBackups(siteId, postId) {
459
+ const site = getSite(siteId);
460
+ if (!site.ssh) {
461
+ throw new Error("Listing postmeta backups requires SSH (WP-CLI). REST API doesn't expose unregistered custom postmeta keys.");
462
+ }
463
+ const r = await sshWpCli(site, `post meta list ${postId} --format=json --fields=meta_key`, { timeout_ms: 3e4 });
464
+ if (r.exitCode !== 0) throw new Error(`wp-cli post meta list failed: ${r.stderr}`);
465
+ const all = JSON.parse(r.stdout);
466
+ const dataBackupKeys = all.filter((m) => m.meta_key.startsWith("_elementor_data_backup_"));
467
+ const settingsBackupKeys = new Set(all.filter((m) => m.meta_key.startsWith("_elementor_page_settings_backup_")).map((m) => m.meta_key));
468
+ return dataBackupKeys.map((m) => {
469
+ const ts = m.meta_key.replace("_elementor_data_backup_", "");
470
+ const expectedSettingsKey = `_elementor_page_settings_backup_${ts}`;
471
+ return {
472
+ meta_key: m.meta_key,
473
+ settings_key: settingsBackupKeys.has(expectedSettingsKey) ? expectedSettingsKey : void 0,
474
+ timestamp: ts
475
+ };
476
+ });
477
+ }
478
+ async function restoreFromFile(siteId, postId, file_path) {
479
+ if (!existsSync(file_path)) throw new Error(`Backup file not found: ${file_path}`);
480
+ const j = JSON.parse(readFileSync2(file_path, "utf8"));
481
+ if (!j._elementor_data) throw new Error("Backup file missing _elementor_data");
482
+ const site = getSite(siteId);
483
+ if (site.ssh) {
484
+ await sshWpCli(site, `post meta update ${postId} _elementor_data ${shellQuote(j._elementor_data)}`, { timeout_ms: 3e4 });
485
+ if (j._elementor_page_settings) {
486
+ await sshWpCli(site, `post meta update ${postId} _elementor_page_settings ${shellQuote(j._elementor_page_settings)}`, { timeout_ms: 3e4 });
487
+ }
488
+ return { restored: true, method: "wp-cli" };
489
+ }
490
+ await wpRequest(`/wp/v2/pages/${postId}`, {
491
+ siteId,
492
+ method: "PUT",
493
+ body: {
494
+ meta: {
495
+ _elementor_data: j._elementor_data,
496
+ ...j._elementor_page_settings ? { _elementor_page_settings: j._elementor_page_settings } : {}
497
+ }
498
+ }
499
+ });
500
+ return { restored: true, method: "rest" };
501
+ }
502
+ function shellQuote(s) {
503
+ return `'${s.replace(/'/g, "'\\''")}'`;
504
+ }
505
+ var BACKUP_DIR;
506
+ var init_backup = __esm({
507
+ "src/elementor/backup.ts"() {
508
+ "use strict";
509
+ init_wp_rest();
510
+ init_ssh_wpcli();
511
+ init_config();
512
+ init_logger();
513
+ BACKUP_DIR = process.env.ELEMENTOR_MCP_BACKUP_DIR ?? join(tmpdir(), "elementor-mcp-backups");
514
+ }
515
+ });
516
+
517
+ // src/elementor/css-flush.ts
518
+ var css_flush_exports = {};
519
+ __export(css_flush_exports, {
520
+ flushCSS: () => flushCSS
521
+ });
522
+ async function flushCSS(siteId, postId) {
523
+ try {
524
+ const url = postId ? `/elementor/v1/css?id=${postId}&action=regenerate` : `/elementor/v1/css?action=regenerate`;
525
+ await wpRequest(url, { siteId, method: "POST" });
526
+ return { method: "rest" };
527
+ } catch (e) {
528
+ logger.debug({ err: e.message }, "REST css flush failed, trying next");
529
+ }
530
+ try {
531
+ const site = getSite(siteId);
532
+ if (site.ssh) {
533
+ const r = await sshWpCli(site, "elementor flush-css");
534
+ if (r.exitCode === 0) return { method: "wp-cli", details: r.stdout };
535
+ }
536
+ } catch (e) {
537
+ logger.debug({ err: e.message }, "WP-CLI css flush failed, trying next");
538
+ }
539
+ try {
540
+ const site = getSite(siteId);
541
+ if (site.ssh) {
542
+ await sshWpCli(site, "option delete _elementor_global_css", { timeout_ms: 3e4 });
543
+ await sshWpCli(site, "post meta delete-all _elementor_css", { timeout_ms: 3e4 });
544
+ return { method: "option-delete" };
545
+ }
546
+ } catch (e) {
547
+ logger.debug({ err: e.message }, "WP-CLI option-delete failed, trying next");
548
+ }
549
+ if (postId) {
550
+ try {
551
+ await wpRequest(`/wp/v2/pages/${postId}`, {
552
+ siteId,
553
+ method: "PUT",
554
+ body: { date: (/* @__PURE__ */ new Date()).toISOString() }
555
+ });
556
+ return { method: "resave" };
557
+ } catch (e) {
558
+ logger.warn({ err: e.message }, "Resave fallback also failed");
559
+ }
560
+ }
561
+ return { method: "none", details: "All CSS flush strategies failed" };
562
+ }
563
+ var init_css_flush = __esm({
564
+ "src/elementor/css-flush.ts"() {
565
+ "use strict";
566
+ init_wp_rest();
567
+ init_ssh_wpcli();
568
+ init_config();
569
+ init_logger();
570
+ }
571
+ });
572
+
573
+ // src/elementor/policies.ts
574
+ var policies_exports = {};
575
+ __export(policies_exports, {
576
+ FORBIDDEN_WPCLI_PATTERNS: () => FORBIDDEN_WPCLI_PATTERNS,
577
+ POLICIES: () => POLICIES,
578
+ isForbiddenWpCli: () => isForbiddenWpCli
579
+ });
580
+ function isForbiddenWpCli(args) {
581
+ for (const p of FORBIDDEN_WPCLI_PATTERNS) {
582
+ if (p.test(args)) return { forbidden: true, reason: `Pattern ${p} is hard-blocked.` };
583
+ }
584
+ return { forbidden: false };
585
+ }
586
+ var POLICIES, FORBIDDEN_WPCLI_PATTERNS;
587
+ var init_policies = __esm({
588
+ "src/elementor/policies.ts"() {
589
+ "use strict";
590
+ POLICIES = {
591
+ // Always backup _elementor_data BEFORE any write
592
+ BACKUP_BEFORE_WRITE: true,
593
+ // Always backup _elementor_page_settings too (it carries page-level CSS, fonts, layout)
594
+ BACKUP_PAGE_SETTINGS: true,
595
+ // Re-validate the JSON after a programmatic edit; abort + auto-restore if invalid
596
+ VALIDATE_JSON_AFTER_EDIT: true,
597
+ // Block edits to global widgets unless caller explicitly opts in
598
+ BLOCK_GLOBAL_WIDGET_WRITES_BY_DEFAULT: true,
599
+ // Confirmation token TTL — short for destructive ops
600
+ CONFIRMATION_TTL_SECONDS: 60,
601
+ // Stricter TTL for global widget ops (less margin for mistakes)
602
+ GLOBAL_WIDGET_CONFIRMATION_TTL_SECONDS: 30,
603
+ // CSS flush is non-optional after _elementor_data writes
604
+ FLUSH_CSS_AFTER_WRITE: true,
605
+ // Maximum size of a single page's _elementor_data we'll touch (sanity bound)
606
+ MAX_ELEMENTOR_DATA_BYTES: 5 * 1024 * 1024,
607
+ // 5 MB
608
+ // wp-cli commands matching this pattern require confirmation
609
+ WP_CLI_DESTRUCTIVE_REQUIRES_CONFIRM: true
610
+ };
611
+ FORBIDDEN_WPCLI_PATTERNS = [
612
+ // Hard 'no' regardless of confirmation — we never accept these
613
+ /\brm\s+-rf\b/i,
614
+ /\bsudo\b/i,
615
+ /\bdb\s+reset\s+--yes\b/i,
616
+ /\bdb\s+drop\s+--yes\b/i
617
+ ];
618
+ }
619
+ });
620
+
621
+ // src/utils/confirmation.ts
622
+ var confirmation_exports = {};
623
+ __export(confirmation_exports, {
624
+ _clearAllConfirmations: () => _clearAllConfirmations,
625
+ consumeConfirmation: () => consumeConfirmation,
626
+ issueConfirmation: () => issueConfirmation
627
+ });
628
+ import { randomBytes } from "crypto";
629
+ function issueConfirmation(intent, payload, ttlSeconds) {
630
+ const token = randomBytes(8).toString("hex");
631
+ pending.set(token, {
632
+ token,
633
+ intent,
634
+ payload,
635
+ expiresAt: Date.now() + ttlSeconds * 1e3
636
+ });
637
+ return token;
638
+ }
639
+ function consumeConfirmation(token, expectedIntent) {
640
+ const c = pending.get(token);
641
+ if (!c) return null;
642
+ pending.delete(token);
643
+ if (c.expiresAt < Date.now()) return null;
644
+ if (c.intent !== expectedIntent) return null;
645
+ return c;
646
+ }
647
+ function _clearAllConfirmations() {
648
+ pending.clear();
649
+ }
650
+ var pending;
651
+ var init_confirmation = __esm({
652
+ "src/utils/confirmation.ts"() {
653
+ "use strict";
654
+ pending = /* @__PURE__ */ new Map();
655
+ }
656
+ });
657
+
658
+ // src/server.ts
659
+ init_config();
660
+ import { readFileSync as readFileSync5 } from "fs";
661
+ import { dirname as dirname2, resolve as resolve2 } from "path";
662
+ import { fileURLToPath as fileURLToPath2 } from "url";
663
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
664
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
665
+ import {
666
+ CallToolRequestSchema,
667
+ ListToolsRequestSchema,
668
+ ListResourcesRequestSchema,
669
+ ReadResourceRequestSchema
670
+ } from "@modelcontextprotocol/sdk/types.js";
671
+ import { zodToJsonSchema } from "zod-to-json-schema";
672
+
673
+ // src/tools/sites.ts
674
+ import { z as z2 } from "zod";
675
+
676
+ // src/types/tool.ts
677
+ function defineTool(def) {
678
+ return def;
679
+ }
248
680
 
249
681
  // src/tools/sites.ts
682
+ init_config();
683
+ init_wp_rest();
250
684
  var listSitesTool = defineTool({
251
685
  name: "list_sites",
252
- description: "List every WordPress site configured in this MCP server's pool. Best called first in a session to discover available sites.",
686
+ description: "List every WordPress site configured. Best called first in a session.",
253
687
  inputSchema: z2.object({}),
254
688
  outputSchema: z2.object({
255
689
  total: z2.number(),
256
690
  default_site_id: z2.string().optional(),
257
- sites: z2.array(
258
- z2.object({
259
- id: z2.string(),
260
- url: z2.string(),
261
- username: z2.string(),
262
- has_ssh: z2.boolean()
263
- })
264
- )
691
+ sites: z2.array(z2.object({
692
+ id: z2.string(),
693
+ url: z2.string(),
694
+ username: z2.string(),
695
+ has_ssh: z2.boolean()
696
+ }))
265
697
  }),
266
698
  annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
267
699
  async handler() {
@@ -269,21 +701,14 @@ var listSitesTool = defineTool({
269
701
  return {
270
702
  total: cfg.sites.length,
271
703
  default_site_id: cfg.default_site_id ?? cfg.sites[0]?.id,
272
- sites: cfg.sites.map((s) => ({
273
- id: s.id,
274
- url: s.url,
275
- username: s.username,
276
- has_ssh: !!s.ssh
277
- }))
704
+ sites: cfg.sites.map((s) => ({ id: s.id, url: s.url, username: s.username, has_ssh: !!s.ssh }))
278
705
  };
279
706
  }
280
707
  });
281
708
  var pingSiteTool = defineTool({
282
709
  name: "ping_site",
283
- description: "Verify connectivity + authentication to a WordPress site. Calls /wp-json/wp/v2/users/me to validate credentials and returns the WP version + Elementor version if detected.",
284
- inputSchema: z2.object({
285
- site_id: z2.string().optional().describe("Site id from list_sites. Defaults to the default site.")
286
- }),
710
+ description: "Verify connectivity + authentication to a WordPress site. Returns user identity + WP/Elementor/Elementor Pro versions if accessible.",
711
+ inputSchema: z2.object({ site_id: z2.string().optional() }),
287
712
  outputSchema: z2.object({
288
713
  ok: z2.boolean(),
289
714
  site_id: z2.string(),
@@ -298,29 +723,19 @@ var pingSiteTool = defineTool({
298
723
  async handler(input) {
299
724
  const site = getSite(input.site_id);
300
725
  try {
301
- const me = await wpRequest(
302
- "/wp/v2/users/me?context=edit",
303
- { siteId: site.id }
304
- );
726
+ const me = await wpRequest("/wp/v2/users/me?context=edit", { siteId: site.id });
305
727
  let wp_version;
306
728
  try {
307
- const health = await wpRequest(
308
- "/wp-site-health/v1/info",
309
- { siteId: site.id }
310
- );
729
+ const health = await wpRequest("/wp-site-health/v1/info", { siteId: site.id });
311
730
  wp_version = health.wordpress?.version;
312
731
  } catch {
313
732
  }
314
733
  let elementor_version;
315
734
  let elementor_pro_version;
316
735
  try {
317
- const plugins = await wpRequest(
318
- "/wp/v2/plugins",
319
- { siteId: site.id }
320
- );
736
+ const plugins = await wpRequest("/wp/v2/plugins", { siteId: site.id });
321
737
  for (const p of plugins) {
322
- if (p.plugin.startsWith("elementor/") && p.plugin.endsWith("/elementor.php"))
323
- elementor_version = p.version;
738
+ if (p.plugin.startsWith("elementor/") && p.plugin.endsWith("/elementor.php")) elementor_version = p.version;
324
739
  if (p.plugin.startsWith("elementor-pro/")) elementor_pro_version = p.version;
325
740
  }
326
741
  } catch {
@@ -335,18 +750,85 @@ var pingSiteTool = defineTool({
335
750
  user: { id: me.id, name: me.name, roles: me.roles }
336
751
  };
337
752
  } catch (e) {
338
- return {
339
- ok: false,
340
- site_id: site.id,
341
- url: site.url,
342
- error: e.message
343
- };
753
+ return { ok: false, site_id: site.id, url: site.url, error: e.message };
754
+ }
755
+ }
756
+ });
757
+ var siteHealthTool = defineTool({
758
+ name: "site_health",
759
+ description: "Comprehensive site health snapshot: WP/PHP/Elementor versions, disk space (if SSH), plugin count, theme info. Aggregates multiple REST calls into a single overview.",
760
+ inputSchema: z2.object({ site_id: z2.string().optional() }),
761
+ outputSchema: z2.object({
762
+ site_id: z2.string(),
763
+ url: z2.string(),
764
+ wp_version: z2.string().optional(),
765
+ php_version: z2.string().optional(),
766
+ elementor_version: z2.string().optional(),
767
+ elementor_pro_version: z2.string().optional(),
768
+ active_theme: z2.string().optional(),
769
+ plugins_total: z2.number().optional(),
770
+ plugins_active: z2.number().optional(),
771
+ plugins_outdated: z2.number().optional(),
772
+ elementor_pages_count: z2.number().optional(),
773
+ errors: z2.array(z2.string())
774
+ }),
775
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
776
+ async handler(input) {
777
+ const site = getSite(input.site_id);
778
+ const errors = [];
779
+ let wp_version, php_version, elementor_version, elementor_pro_version, active_theme;
780
+ let plugins_total, plugins_active, plugins_outdated, elementor_pages_count;
781
+ try {
782
+ const info = await wpRequest("/wp-site-health/v1/info", { siteId: site.id });
783
+ wp_version = info.wordpress?.version;
784
+ php_version = info["wp-server"]?.fields?.php_version?.value;
785
+ } catch (e) {
786
+ errors.push("site-health: " + e.message);
787
+ }
788
+ try {
789
+ const plugins = await wpRequest("/wp/v2/plugins", { siteId: site.id });
790
+ plugins_total = plugins.length;
791
+ plugins_active = plugins.filter((p) => p.status === "active").length;
792
+ for (const p of plugins) {
793
+ if (p.plugin.startsWith("elementor/") && p.plugin.endsWith("/elementor.php")) elementor_version = p.version;
794
+ if (p.plugin.startsWith("elementor-pro/")) elementor_pro_version = p.version;
795
+ }
796
+ } catch (e) {
797
+ errors.push("plugins: " + e.message);
798
+ }
799
+ try {
800
+ const themes = await wpRequest("/wp/v2/themes", { siteId: site.id });
801
+ const active = themes.find((t) => t.status === "active");
802
+ active_theme = active?.name?.raw;
803
+ } catch (e) {
804
+ errors.push("themes: " + e.message);
805
+ }
806
+ try {
807
+ const pages = await wpRequest("/wp/v2/pages", { siteId: site.id, query: { meta_key: "_elementor_edit_mode", meta_value: "builder", per_page: 1, _fields: "id" } });
808
+ elementor_pages_count = pages.length > 0 ? -1 : 0;
809
+ } catch (e) {
810
+ errors.push("pages: " + e.message);
344
811
  }
812
+ return {
813
+ site_id: site.id,
814
+ url: site.url,
815
+ wp_version,
816
+ php_version,
817
+ elementor_version,
818
+ elementor_pro_version,
819
+ active_theme,
820
+ plugins_total,
821
+ plugins_active,
822
+ plugins_outdated,
823
+ elementor_pages_count,
824
+ errors
825
+ };
345
826
  }
346
827
  });
347
828
 
348
829
  // src/tools/pages.ts
349
830
  import { z as z3 } from "zod";
831
+ init_wp_rest();
350
832
 
351
833
  // src/elementor/data-parser.ts
352
834
  function parseElementorData(raw) {
@@ -422,59 +904,101 @@ function summarize(data) {
422
904
  return { totalElements, sections, containers, columns, widgets, byWidgetType, maxDepth };
423
905
  }
424
906
 
425
- // src/elementor/safety.ts
426
- async function backupElementorData(siteId, postId) {
427
- const current = await wpRequest(`/wp/v2/pages/${postId}?context=edit&_fields=meta`, { siteId });
428
- const raw = current.meta?._elementor_data ?? "[]";
429
- const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
430
- const meta_key = `_elementor_data_backup_${ts}`;
431
- await wpRequest(`/wp/v2/pages/${postId}`, {
907
+ // src/tools/pages.ts
908
+ init_backup();
909
+ init_css_flush();
910
+
911
+ // src/elementor/validator.ts
912
+ init_policies();
913
+ function validateElementorData(input) {
914
+ const errors = [];
915
+ const warnings = [];
916
+ if (typeof input === "string" && input.length > POLICIES.MAX_ELEMENTOR_DATA_BYTES) {
917
+ errors.push(`_elementor_data exceeds ${POLICIES.MAX_ELEMENTOR_DATA_BYTES} bytes (got ${input.length})`);
918
+ return { valid: false, errors, warnings };
919
+ }
920
+ let data;
921
+ try {
922
+ data = parseElementorData(input);
923
+ } catch (e) {
924
+ errors.push(`JSON parse failed: ${e.message}`);
925
+ return { valid: false, errors, warnings };
926
+ }
927
+ function validateElement(el, depth, path) {
928
+ const here = [...path, el.id ?? "?"];
929
+ if (typeof el.id !== "string" || el.id.length === 0) {
930
+ errors.push(`element at ${here.join(".")}: missing or empty id`);
931
+ }
932
+ if (!["section", "container", "column", "widget"].includes(el.elType)) {
933
+ errors.push(`element ${el.id}: unknown elType "${el.elType}"`);
934
+ }
935
+ if (el.elType === "widget" && (typeof el.widgetType !== "string" || el.widgetType.length === 0)) {
936
+ errors.push(`widget ${el.id}: missing widgetType`);
937
+ }
938
+ if (el.settings === null || el.settings === void 0 || typeof el.settings !== "object" || Array.isArray(el.settings)) {
939
+ errors.push(`element ${el.id}: settings must be an object (got ${typeof el.settings})`);
940
+ }
941
+ if (depth > 20) warnings.push(`element ${el.id}: depth ${depth} is unusually deep`);
942
+ if (Array.isArray(el.elements)) {
943
+ for (const child of el.elements) validateElement(child, depth + 1, here);
944
+ }
945
+ }
946
+ if (!Array.isArray(data)) {
947
+ errors.push("Top-level must be an array");
948
+ return { valid: false, errors, warnings };
949
+ }
950
+ for (const el of data) validateElement(el, 0, []);
951
+ return { valid: errors.length === 0, errors, warnings };
952
+ }
953
+
954
+ // src/elementor/globals.ts
955
+ init_wp_rest();
956
+ async function listGlobalWidgets(siteId) {
957
+ const items = await wpRequest("/wp/v2/elementor_library", {
432
958
  siteId,
433
- method: "PUT",
434
- body: { meta: { [meta_key]: raw } }
959
+ query: {
960
+ context: "edit",
961
+ per_page: 100,
962
+ _fields: "id,title,meta"
963
+ }
964
+ });
965
+ const widgetsOnly = items.filter((t) => t.meta?._elementor_template_type === "widget");
966
+ return widgetsOnly.map((t) => {
967
+ const data = parseElementorData(t.meta?._elementor_data ?? "[]");
968
+ let widget_type;
969
+ for (const { element } of walkElements(data)) {
970
+ if (element.elType === "widget") {
971
+ widget_type = element.widgetType;
972
+ break;
973
+ }
974
+ }
975
+ return {
976
+ template_id: t.id,
977
+ title: t.title.rendered,
978
+ widget_type
979
+ };
435
980
  });
436
- return { meta_key, size_bytes: raw.length };
437
981
  }
438
- async function flushElementorCSS(siteId, postId) {
982
+ function findGlobalReferences(data) {
983
+ const out = [];
439
984
  try {
440
- await wpRequest(`/elementor/v1/css?id=${postId}&action=regenerate`, { siteId, method: "POST" });
441
- return { method: "rest" };
985
+ for (const { element } of walkElements(parseElementorData(data))) {
986
+ if (element.elType === "widget" && element.widgetType === "global") {
987
+ const tid = element.settings.template_id ?? element.settings.templateID;
988
+ if (typeof tid === "number") out.push({ widget_id: element.id, template_id: tid });
989
+ }
990
+ }
442
991
  } catch {
443
- await wpRequest(`/wp/v2/pages/${postId}`, {
444
- siteId,
445
- method: "PUT",
446
- body: { date: (/* @__PURE__ */ new Date()).toISOString() }
447
- });
448
- return { method: "resave" };
449
992
  }
450
- }
451
-
452
- // src/utils/confirmation.ts
453
- import { randomBytes } from "crypto";
454
- var pending = /* @__PURE__ */ new Map();
455
- function issueConfirmation(intent, payload, ttlSeconds) {
456
- const token = randomBytes(8).toString("hex");
457
- pending.set(token, {
458
- token,
459
- intent,
460
- payload,
461
- expiresAt: Date.now() + ttlSeconds * 1e3
462
- });
463
- return token;
464
- }
465
- function consumeConfirmation(token, expectedIntent) {
466
- const c = pending.get(token);
467
- if (!c) return null;
468
- pending.delete(token);
469
- if (c.expiresAt < Date.now()) return null;
470
- if (c.intent !== expectedIntent) return null;
471
- return c;
993
+ return out;
472
994
  }
473
995
 
474
996
  // src/tools/pages.ts
997
+ init_confirmation();
998
+ init_policies();
475
999
  var listElementorPagesTool = defineTool({
476
1000
  name: "list_elementor_pages",
477
- description: "List pages on a site that are built with Elementor (i.e. have _elementor_edit_mode = 'builder'). Returns id, title, slug, status, modified date.",
1001
+ description: "List pages built with Elementor (have _elementor_edit_mode = 'builder'). Returns id, title, slug, status, modified date.",
478
1002
  inputSchema: z3.object({
479
1003
  site_id: z3.string().optional(),
480
1004
  per_page: z3.number().int().min(1).max(100).default(25),
@@ -482,16 +1006,14 @@ var listElementorPagesTool = defineTool({
482
1006
  }),
483
1007
  outputSchema: z3.object({
484
1008
  total: z3.number(),
485
- pages: z3.array(
486
- z3.object({
487
- id: z3.number(),
488
- title: z3.string(),
489
- slug: z3.string(),
490
- status: z3.string(),
491
- link: z3.string(),
492
- modified: z3.string()
493
- })
494
- )
1009
+ pages: z3.array(z3.object({
1010
+ id: z3.number(),
1011
+ title: z3.string(),
1012
+ slug: z3.string(),
1013
+ status: z3.string(),
1014
+ link: z3.string(),
1015
+ modified: z3.string()
1016
+ }))
495
1017
  }),
496
1018
  annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
497
1019
  async handler(input) {
@@ -521,11 +1043,11 @@ var listElementorPagesTool = defineTool({
521
1043
  });
522
1044
  var readPageElementorTool = defineTool({
523
1045
  name: "read_page_elementor",
524
- description: "Fetch the raw _elementor_data of a page and return a structured summary (counts by widget type, depth, total elements). Optionally returns the full parsed tree (verbose=true) which may be very large.",
1046
+ description: "Fetch a page's Elementor data structure summary. With verbose=true returns the full parsed tree (potentially MBs).",
525
1047
  inputSchema: z3.object({
526
1048
  site_id: z3.string().optional(),
527
1049
  page_id: z3.number().int().positive(),
528
- verbose: z3.boolean().default(false).describe("If true, return the entire parsed Elementor data tree (can be MBs).")
1050
+ verbose: z3.boolean().default(false)
529
1051
  }),
530
1052
  outputSchema: z3.object({
531
1053
  page_id: z3.number(),
@@ -539,6 +1061,7 @@ var readPageElementorTool = defineTool({
539
1061
  maxDepth: z3.number(),
540
1062
  byWidgetType: z3.record(z3.number())
541
1063
  }),
1064
+ global_references: z3.array(z3.object({ widget_id: z3.string(), template_id: z3.number() })),
542
1065
  data: z3.array(z3.any()).optional()
543
1066
  }),
544
1067
  annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
@@ -549,113 +1072,426 @@ var readPageElementorTool = defineTool({
549
1072
  );
550
1073
  const raw = page.meta?._elementor_data ?? "[]";
551
1074
  const data = parseElementorData(raw);
552
- const summary = summarize(data);
553
1075
  return {
554
1076
  page_id: page.id,
555
1077
  title: page.title.rendered,
556
- summary,
1078
+ summary: summarize(data),
1079
+ global_references: findGlobalReferences(raw),
557
1080
  data: input.verbose ? data : void 0
558
1081
  };
559
1082
  }
560
1083
  });
1084
+ var listWidgetsInPageTool = defineTool({
1085
+ name: "list_widgets_in_page",
1086
+ description: "Flat list of every widget in a page with id, type, parent path, and an excerpt of the first text setting (for spot-checking before find/replace).",
1087
+ inputSchema: z3.object({
1088
+ site_id: z3.string().optional(),
1089
+ page_id: z3.number().int().positive(),
1090
+ widget_type: z3.string().optional()
1091
+ }),
1092
+ outputSchema: z3.object({
1093
+ page_id: z3.number(),
1094
+ total: z3.number(),
1095
+ widgets: z3.array(z3.object({
1096
+ widget_id: z3.string(),
1097
+ widget_type: z3.string(),
1098
+ path: z3.array(z3.string()),
1099
+ excerpt: z3.string().optional()
1100
+ }))
1101
+ }),
1102
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
1103
+ async handler(input) {
1104
+ const page = await wpRequest(
1105
+ `/wp/v2/pages/${input.page_id}?context=edit&_fields=id,meta`,
1106
+ { siteId: input.site_id }
1107
+ );
1108
+ const data = parseElementorData(page.meta?._elementor_data ?? "[]");
1109
+ const widgets = [];
1110
+ for (const { element, path } of walkElements(data)) {
1111
+ if (element.elType !== "widget") continue;
1112
+ if (input.widget_type && element.widgetType !== input.widget_type) continue;
1113
+ let excerpt;
1114
+ for (const v of Object.values(element.settings ?? {})) {
1115
+ if (typeof v === "string" && v.length > 0) {
1116
+ excerpt = v.replace(/<[^>]+>/g, "").slice(0, 80);
1117
+ break;
1118
+ }
1119
+ }
1120
+ widgets.push({
1121
+ widget_id: element.id,
1122
+ widget_type: element.widgetType ?? "unknown",
1123
+ path: path.slice(0, -1),
1124
+ excerpt
1125
+ });
1126
+ }
1127
+ return { page_id: page.id, total: widgets.length, widgets };
1128
+ }
1129
+ });
1130
+ var listGlobalWidgetsTool = defineTool({
1131
+ name: "list_global_widgets",
1132
+ description: "List all global widgets on a site (Elementor library entries of type 'widget'). These are shared across pages \u2014 editing one affects every page using it.",
1133
+ inputSchema: z3.object({ site_id: z3.string().optional() }),
1134
+ outputSchema: z3.object({
1135
+ total: z3.number(),
1136
+ globals: z3.array(z3.object({
1137
+ template_id: z3.number(),
1138
+ title: z3.string(),
1139
+ widget_type: z3.string().optional()
1140
+ }))
1141
+ }),
1142
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
1143
+ async handler(input) {
1144
+ const globals = await listGlobalWidgets(input.site_id);
1145
+ return { total: globals.length, globals };
1146
+ }
1147
+ });
1148
+ var preflightCheckTool = defineTool({
1149
+ name: "preflight_check",
1150
+ description: "Validate a page is safe to edit. Checks: page exists, is Elementor-built, data parses cleanly, references valid global widgets, isn't currently locked by another editor.",
1151
+ inputSchema: z3.object({
1152
+ site_id: z3.string().optional(),
1153
+ page_id: z3.number().int().positive()
1154
+ }),
1155
+ outputSchema: z3.object({
1156
+ safe_to_edit: z3.boolean(),
1157
+ page_id: z3.number(),
1158
+ title: z3.string(),
1159
+ issues: z3.array(z3.string()),
1160
+ warnings: z3.array(z3.string()),
1161
+ data_bytes: z3.number(),
1162
+ global_widget_references: z3.number()
1163
+ }),
1164
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
1165
+ async handler(input) {
1166
+ const issues = [];
1167
+ const warnings = [];
1168
+ let title = "";
1169
+ let data_bytes = 0;
1170
+ let global_widget_references = 0;
1171
+ try {
1172
+ const page = await wpRequest(
1173
+ `/wp/v2/pages/${input.page_id}?context=edit`,
1174
+ { siteId: input.site_id }
1175
+ );
1176
+ title = page.title.rendered;
1177
+ const raw = page.meta?._elementor_data ?? "[]";
1178
+ data_bytes = raw.length;
1179
+ if (page.meta?._elementor_edit_mode !== "builder") {
1180
+ issues.push("Page is not in Elementor builder mode");
1181
+ }
1182
+ const v = validateElementorData(raw);
1183
+ if (!v.valid) issues.push(...v.errors);
1184
+ warnings.push(...v.warnings);
1185
+ global_widget_references = findGlobalReferences(raw).length;
1186
+ if (global_widget_references > 0) {
1187
+ warnings.push(`Page references ${global_widget_references} global widget(s). Edits via find_replace will NOT affect the globals themselves \u2014 modify the global template directly if needed.`);
1188
+ }
1189
+ if (data_bytes > POLICIES.MAX_ELEMENTOR_DATA_BYTES) {
1190
+ issues.push(`Elementor data exceeds policy max (${POLICIES.MAX_ELEMENTOR_DATA_BYTES} bytes)`);
1191
+ }
1192
+ } catch (e) {
1193
+ issues.push(`Cannot fetch page: ${e.message}`);
1194
+ }
1195
+ return { safe_to_edit: issues.length === 0, page_id: input.page_id, title, issues, warnings, data_bytes, global_widget_references };
1196
+ }
1197
+ });
561
1198
  var findReplaceTool = defineTool({
562
1199
  name: "elementor_find_replace",
563
- description: "Find/replace plain text in every widget's settings on a single page. TWO-CALL DESTRUCTIVE FLOW: first call without `confirmation` performs a dry-run and returns a confirmation token + match count. Second call with the token actually applies the change after backing up the page's elementor data.",
1200
+ description: "Find/replace plain text in every widget on one page. TWO-CALL FLOW: dry-run returns match_count + detailed widget hits + confirmation_token. Second call with token applies the change with auto-backup + JSON validation + auto-rollback if validation fails + CSS flush.",
564
1201
  inputSchema: z3.object({
565
1202
  site_id: z3.string().optional(),
566
1203
  page_id: z3.number().int().positive(),
567
1204
  find: z3.string().min(1),
568
1205
  replace: z3.string(),
569
- widget_type: z3.string().optional().describe("Restrict to one widget type, e.g. 'heading'."),
1206
+ widget_type: z3.string().optional(),
570
1207
  case_sensitive: z3.boolean().default(false),
571
- confirmation: z3.string().optional().describe("Token returned from the dry-run call.")
1208
+ backup_to_file: z3.boolean().default(false).describe("Also dump backup to /tmp/elementor-mcp-backups/"),
1209
+ confirmation: z3.string().optional()
572
1210
  }),
573
1211
  outputSchema: z3.object({
574
- mode: z3.enum(["dry_run", "applied"]),
1212
+ mode: z3.enum(["dry_run", "applied", "rolled_back"]),
575
1213
  page_id: z3.number(),
576
1214
  match_count: z3.number(),
1215
+ affected_widgets: z3.array(z3.object({
1216
+ widget_id: z3.string(),
1217
+ widget_type: z3.string(),
1218
+ before: z3.string(),
1219
+ after: z3.string()
1220
+ })).optional(),
577
1221
  confirmation_token: z3.string().optional(),
578
1222
  expires_in_seconds: z3.number().optional(),
579
1223
  backup_meta_key: z3.string().optional(),
580
- css_flush: z3.string().optional()
1224
+ backup_file: z3.string().optional(),
1225
+ css_flush: z3.string().optional(),
1226
+ validation_error: z3.string().optional()
581
1227
  }),
582
1228
  annotations: { destructiveHint: true, idempotentHint: false, openWorldHint: true },
583
1229
  async handler(input) {
584
- const cfg = loadConfig();
585
1230
  const page = await wpRequest(
586
- `/wp/v2/pages/${input.page_id}?context=edit`,
1231
+ `/wp/v2/pages/${input.page_id}?context=edit&_fields=id,meta`,
587
1232
  { siteId: input.site_id }
588
1233
  );
589
1234
  const raw = page.meta?._elementor_data ?? "[]";
590
- const data = parseElementorData(raw);
591
- const dry = findReplaceInWidgets(JSON.parse(JSON.stringify(data)), input.find, input.replace, {
1235
+ const dryData = parseElementorData(raw);
1236
+ const dry = findReplaceInWidgets(JSON.parse(JSON.stringify(dryData)), input.find, input.replace, {
592
1237
  widgetType: input.widget_type,
593
1238
  caseSensitive: input.case_sensitive
594
1239
  });
1240
+ const affected = [];
1241
+ const beforeData = parseElementorData(raw);
1242
+ const afterCopy = JSON.parse(JSON.stringify(beforeData));
1243
+ findReplaceInWidgets(afterCopy, input.find, input.replace, {
1244
+ widgetType: input.widget_type,
1245
+ caseSensitive: input.case_sensitive
1246
+ });
1247
+ function collectAffected(orig, modified) {
1248
+ const beforeMap = /* @__PURE__ */ new Map();
1249
+ for (const { element } of walkElements(orig)) {
1250
+ if (element.elType !== "widget") continue;
1251
+ const firstStr = Object.values(element.settings ?? {}).find((v) => typeof v === "string");
1252
+ if (firstStr) beforeMap.set(element.id, { type: element.widgetType ?? "?", first: firstStr });
1253
+ }
1254
+ for (const { element } of walkElements(modified)) {
1255
+ if (element.elType !== "widget") continue;
1256
+ const firstStr = Object.values(element.settings ?? {}).find((v) => typeof v === "string");
1257
+ const b = beforeMap.get(element.id);
1258
+ if (b && firstStr && b.first !== firstStr) {
1259
+ affected.push({
1260
+ widget_id: element.id,
1261
+ widget_type: element.widgetType ?? "?",
1262
+ before: b.first.slice(0, 120),
1263
+ after: firstStr.slice(0, 120)
1264
+ });
1265
+ }
1266
+ }
1267
+ }
1268
+ collectAffected(beforeData, afterCopy);
595
1269
  if (!input.confirmation) {
596
1270
  if (dry.replacementCount === 0) {
597
- return { mode: "dry_run", page_id: input.page_id, match_count: 0 };
1271
+ return {
1272
+ mode: "dry_run",
1273
+ page_id: input.page_id,
1274
+ match_count: 0,
1275
+ affected_widgets: []
1276
+ };
598
1277
  }
599
1278
  const token = issueConfirmation(
600
1279
  "elementor_find_replace",
601
1280
  { page_id: input.page_id, find: input.find, replace: input.replace },
602
- cfg.confirmation_ttl_seconds
1281
+ POLICIES.CONFIRMATION_TTL_SECONDS
603
1282
  );
604
1283
  return {
605
1284
  mode: "dry_run",
606
1285
  page_id: input.page_id,
607
1286
  match_count: dry.replacementCount,
1287
+ affected_widgets: affected.slice(0, 25),
608
1288
  confirmation_token: token,
609
- expires_in_seconds: cfg.confirmation_ttl_seconds
1289
+ expires_in_seconds: POLICIES.CONFIRMATION_TTL_SECONDS
610
1290
  };
611
1291
  }
612
1292
  const conf = consumeConfirmation(input.confirmation, "elementor_find_replace");
613
1293
  if (!conf) throw new Error("Invalid or expired confirmation token");
614
- const original = conf.payload;
615
- if (original.page_id !== input.page_id || original.find !== input.find || original.replace !== input.replace) {
1294
+ const o = conf.payload;
1295
+ if (o.page_id !== input.page_id || o.find !== input.find || o.replace !== input.replace) {
616
1296
  throw new Error("Confirmation parameters don't match the original dry-run");
617
1297
  }
618
- const backup = await backupElementorData(input.site_id, input.page_id);
619
- const applied = findReplaceInWidgets(parseElementorData(raw), input.find, input.replace, {
1298
+ const backup = await fullBackup(input.site_id, input.page_id, { to_file: input.backup_to_file });
1299
+ const newData = parseElementorData(raw);
1300
+ findReplaceInWidgets(newData, input.find, input.replace, {
620
1301
  widgetType: input.widget_type,
621
1302
  caseSensitive: input.case_sensitive
622
1303
  });
1304
+ const serialized = serializeElementorData(newData);
1305
+ const validation = validateElementorData(serialized);
1306
+ if (!validation.valid) {
1307
+ return {
1308
+ mode: "rolled_back",
1309
+ page_id: input.page_id,
1310
+ match_count: dry.replacementCount,
1311
+ affected_widgets: affected.slice(0, 25),
1312
+ backup_meta_key: backup.meta_key,
1313
+ validation_error: validation.errors.join("; ")
1314
+ };
1315
+ }
623
1316
  await wpRequest(`/wp/v2/pages/${input.page_id}`, {
624
1317
  siteId: input.site_id,
625
1318
  method: "PUT",
626
- body: { meta: { _elementor_data: serializeElementorData(applied.data) } }
1319
+ body: { meta: { _elementor_data: serialized } }
627
1320
  });
628
- const flush = await flushElementorCSS(input.site_id, input.page_id);
1321
+ const flush = POLICIES.FLUSH_CSS_AFTER_WRITE ? await flushCSS(input.site_id, input.page_id) : { method: "none" };
629
1322
  return {
630
1323
  mode: "applied",
631
1324
  page_id: input.page_id,
632
- match_count: applied.replacementCount,
1325
+ match_count: dry.replacementCount,
1326
+ affected_widgets: affected.slice(0, 25),
633
1327
  backup_meta_key: backup.meta_key,
1328
+ backup_file: backup.file_path,
1329
+ css_flush: flush.method
1330
+ };
1331
+ }
1332
+ });
1333
+ var listElementorBackupsTool = defineTool({
1334
+ name: "list_elementor_backups",
1335
+ description: "List timestamped backups of a page's Elementor data (created by previous edit ops). Use restore_elementor_backup with one of these meta keys to roll back.",
1336
+ inputSchema: z3.object({
1337
+ site_id: z3.string().optional(),
1338
+ page_id: z3.number().int().positive()
1339
+ }),
1340
+ outputSchema: z3.object({
1341
+ page_id: z3.number(),
1342
+ total: z3.number(),
1343
+ backups: z3.array(z3.object({
1344
+ meta_key: z3.string(),
1345
+ settings_key: z3.string().optional(),
1346
+ timestamp: z3.string()
1347
+ }))
1348
+ }),
1349
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
1350
+ async handler(input) {
1351
+ const backups = await listBackups(input.site_id, input.page_id);
1352
+ return {
1353
+ page_id: input.page_id,
1354
+ total: backups.length,
1355
+ backups
1356
+ };
1357
+ }
1358
+ });
1359
+ var restoreElementorBackupTool = defineTool({
1360
+ name: "restore_elementor_backup",
1361
+ description: "Restore a page's _elementor_data and _elementor_page_settings from a backup created by a previous edit. TWO-CALL FLOW with confirmation token.",
1362
+ inputSchema: z3.object({
1363
+ site_id: z3.string().optional(),
1364
+ page_id: z3.number().int().positive(),
1365
+ backup_meta_key: z3.string().describe("From list_elementor_backups."),
1366
+ settings_meta_key: z3.string().optional(),
1367
+ confirmation: z3.string().optional()
1368
+ }),
1369
+ outputSchema: z3.object({
1370
+ mode: z3.enum(["dry_run", "restored"]),
1371
+ page_id: z3.number(),
1372
+ backup_meta_key: z3.string(),
1373
+ settings_meta_key: z3.string().optional(),
1374
+ confirmation_token: z3.string().optional(),
1375
+ expires_in_seconds: z3.number().optional(),
1376
+ css_flush: z3.string().optional(),
1377
+ pre_restore_backup_meta_key: z3.string().optional()
1378
+ }),
1379
+ annotations: { destructiveHint: true, idempotentHint: false, openWorldHint: true },
1380
+ async handler(input) {
1381
+ if (!input.confirmation) {
1382
+ const token = issueConfirmation("restore_elementor_backup", {
1383
+ page_id: input.page_id,
1384
+ backup_meta_key: input.backup_meta_key
1385
+ }, POLICIES.CONFIRMATION_TTL_SECONDS);
1386
+ return {
1387
+ mode: "dry_run",
1388
+ page_id: input.page_id,
1389
+ backup_meta_key: input.backup_meta_key,
1390
+ settings_meta_key: input.settings_meta_key,
1391
+ confirmation_token: token,
1392
+ expires_in_seconds: POLICIES.CONFIRMATION_TTL_SECONDS
1393
+ };
1394
+ }
1395
+ const conf = consumeConfirmation(input.confirmation, "restore_elementor_backup");
1396
+ if (!conf) throw new Error("Invalid or expired confirmation token");
1397
+ const pre = await fullBackup(input.site_id, input.page_id);
1398
+ await restoreBackup(input.site_id, input.page_id, input.backup_meta_key, input.settings_meta_key);
1399
+ const flush = await flushCSS(input.site_id, input.page_id);
1400
+ return {
1401
+ mode: "restored",
1402
+ page_id: input.page_id,
1403
+ backup_meta_key: input.backup_meta_key,
1404
+ settings_meta_key: input.settings_meta_key,
1405
+ css_flush: flush.method,
1406
+ pre_restore_backup_meta_key: pre.meta_key
1407
+ };
1408
+ }
1409
+ });
1410
+ var duplicateElementorPageTool = defineTool({
1411
+ name: "duplicate_elementor_page",
1412
+ description: "Duplicate an Elementor page within the same site. Creates a new draft page, copies _elementor_data + _elementor_page_settings + _elementor_edit_mode, flushes CSS.",
1413
+ inputSchema: z3.object({
1414
+ site_id: z3.string().optional(),
1415
+ source_page_id: z3.number().int().positive(),
1416
+ new_title: z3.string().optional().describe("Defaults to '<original> (Copy)'"),
1417
+ status: z3.enum(["draft", "publish", "private"]).default("draft")
1418
+ }),
1419
+ outputSchema: z3.object({
1420
+ new_page_id: z3.number(),
1421
+ new_page_url: z3.string(),
1422
+ source_page_id: z3.number(),
1423
+ title: z3.string(),
1424
+ status: z3.string(),
1425
+ css_flush: z3.string().optional()
1426
+ }),
1427
+ annotations: { destructiveHint: false, idempotentHint: false, openWorldHint: true },
1428
+ async handler(input) {
1429
+ const source = await wpRequest(
1430
+ `/wp/v2/pages/${input.source_page_id}?context=edit`,
1431
+ { siteId: input.site_id }
1432
+ );
1433
+ const title = input.new_title ?? `${source.title.rendered} (Copy)`;
1434
+ const data_raw = source.meta?._elementor_data ?? "[]";
1435
+ const settings_raw = source.meta?._elementor_page_settings ?? "{}";
1436
+ const created = await wpRequest("/wp/v2/pages", {
1437
+ siteId: input.site_id,
1438
+ method: "POST",
1439
+ body: {
1440
+ title,
1441
+ status: input.status,
1442
+ meta: {
1443
+ _elementor_data: data_raw,
1444
+ _elementor_page_settings: parseSettingsForRest(settings_raw),
1445
+ _elementor_edit_mode: "builder"
1446
+ }
1447
+ }
1448
+ });
1449
+ const flush = await flushCSS(input.site_id, created.id);
1450
+ return {
1451
+ new_page_id: created.id,
1452
+ new_page_url: created.link,
1453
+ source_page_id: input.source_page_id,
1454
+ title,
1455
+ status: input.status,
634
1456
  css_flush: flush.method
635
1457
  };
636
1458
  }
637
1459
  });
1460
+ function parseSettingsForRest(raw) {
1461
+ if (typeof raw === "object" && raw !== null) return raw;
1462
+ if (typeof raw === "string") {
1463
+ if (!raw.trim()) return {};
1464
+ try {
1465
+ return JSON.parse(raw);
1466
+ } catch {
1467
+ return {};
1468
+ }
1469
+ }
1470
+ return {};
1471
+ }
638
1472
 
639
1473
  // src/tools/templates.ts
640
1474
  import { z as z4 } from "zod";
1475
+ init_wp_rest();
1476
+ var TEMPLATE_TYPES = ["section", "page", "popup", "header", "footer", "archive", "single", "widget", "any"];
641
1477
  var listTemplatesTool = defineTool({
642
1478
  name: "list_elementor_templates",
643
- description: "List Elementor library templates on a site (saved sections, pages, popups). Type can be filtered: 'section', 'page', 'popup', 'header', 'footer'.",
1479
+ description: "List Elementor library entries on a site: saved sections, pages, popups, headers/footers (Theme Builder Pro), single/archive templates (Theme Builder Pro), and global widgets. Type filter narrows results.",
644
1480
  inputSchema: z4.object({
645
1481
  site_id: z4.string().optional(),
646
- type: z4.enum(["section", "page", "popup", "header", "footer", "any"]).default("any"),
1482
+ type: z4.enum(TEMPLATE_TYPES).default("any"),
647
1483
  per_page: z4.number().int().min(1).max(100).default(50)
648
1484
  }),
649
1485
  outputSchema: z4.object({
650
1486
  total: z4.number(),
651
- templates: z4.array(
652
- z4.object({
653
- id: z4.number(),
654
- title: z4.string(),
655
- type: z4.string(),
656
- modified: z4.string()
657
- })
658
- )
1487
+ templates: z4.array(z4.object({
1488
+ id: z4.number(),
1489
+ title: z4.string(),
1490
+ type: z4.string(),
1491
+ is_theme_builder: z4.boolean(),
1492
+ display_conditions_count: z4.number(),
1493
+ modified: z4.string()
1494
+ }))
659
1495
  }),
660
1496
  annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
661
1497
  async handler(input) {
@@ -664,28 +1500,35 @@ var listTemplatesTool = defineTool({
664
1500
  context: "edit",
665
1501
  _fields: "id,title,modified,meta"
666
1502
  };
667
- if (input.type !== "any") {
668
- query.meta_key = "_elementor_template_type";
669
- query.meta_value = input.type;
670
- }
671
- const items = await wpRequest("/wp/v2/elementor_library", {
672
- siteId: input.site_id,
673
- query
674
- });
1503
+ const allItems = await wpRequest("/wp/v2/elementor_library", { siteId: input.site_id, query });
1504
+ const items = input.type === "any" ? allItems : allItems.filter((t) => t.meta?._elementor_template_type === input.type);
1505
+ const themeBuilderTypes = /* @__PURE__ */ new Set(["header", "footer", "archive", "single"]);
675
1506
  return {
676
1507
  total: items.length,
677
- templates: items.map((t) => ({
678
- id: t.id,
679
- title: t.title.rendered,
680
- type: t.meta?._elementor_template_type ?? "unknown",
681
- modified: t.modified
682
- }))
1508
+ templates: items.map((t) => {
1509
+ const type = t.meta?._elementor_template_type ?? "unknown";
1510
+ let conds = 0;
1511
+ try {
1512
+ const c = t.meta?._elementor_conditions;
1513
+ if (typeof c === "string") conds = JSON.parse(c).length;
1514
+ else if (Array.isArray(c)) conds = c.length;
1515
+ } catch {
1516
+ }
1517
+ return {
1518
+ id: t.id,
1519
+ title: t.title.rendered,
1520
+ type,
1521
+ is_theme_builder: themeBuilderTypes.has(type),
1522
+ display_conditions_count: conds,
1523
+ modified: t.modified
1524
+ };
1525
+ })
683
1526
  };
684
1527
  }
685
1528
  });
686
1529
  var exportTemplateTool = defineTool({
687
1530
  name: "export_elementor_template",
688
- description: "Export an Elementor template as a portable JSON object. Output is the same structure Elementor expects on import. Use it to copy sections between sites.",
1531
+ description: "Export an Elementor template (section, page, header, footer, etc.) as a portable JSON object. Output goes into import_elementor_template on another site.",
689
1532
  inputSchema: z4.object({
690
1533
  site_id: z4.string().optional(),
691
1534
  template_id: z4.number().int().positive()
@@ -694,18 +1537,12 @@ var exportTemplateTool = defineTool({
694
1537
  template_id: z4.number(),
695
1538
  title: z4.string(),
696
1539
  type: z4.string(),
697
- summary: z4.object({
698
- totalElements: z4.number(),
699
- widgets: z4.number(),
700
- sections: z4.number()
701
- }),
702
- portable_json: z4.string().describe("JSON-stringified payload ready to import via import_elementor_template.")
1540
+ summary: z4.object({ totalElements: z4.number(), widgets: z4.number(), sections: z4.number() }),
1541
+ portable_json: z4.string()
703
1542
  }),
704
1543
  annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
705
1544
  async handler(input) {
706
- const tpl = await wpRequest(`/wp/v2/elementor_library/${input.template_id}?context=edit`, {
707
- siteId: input.site_id
708
- });
1545
+ const tpl = await wpRequest(`/wp/v2/elementor_library/${input.template_id}?context=edit`, { siteId: input.site_id });
709
1546
  const data = parseElementorData(tpl.meta?._elementor_data ?? "[]");
710
1547
  const sum = summarize(data);
711
1548
  const portable = {
@@ -725,10 +1562,10 @@ var exportTemplateTool = defineTool({
725
1562
  });
726
1563
  var importTemplateTool = defineTool({
727
1564
  name: "import_elementor_template",
728
- description: "Import an Elementor template (output of export_elementor_template) into a target site as a new template entry. Useful for syncing reusable sections across an agency's site fleet.",
1565
+ description: "Import a portable template JSON (output of export_elementor_template) into a target site as a new library entry. Useful for cross-site template sync.",
729
1566
  inputSchema: z4.object({
730
- site_id: z4.string().optional().describe("Target site id."),
731
- portable_json: z4.string().describe("JSON-stringified payload from export_elementor_template."),
1567
+ site_id: z4.string().optional(),
1568
+ portable_json: z4.string(),
732
1569
  override_title: z4.string().optional()
733
1570
  }),
734
1571
  outputSchema: z4.object({
@@ -760,40 +1597,493 @@ var importTemplateTool = defineTool({
760
1597
  }
761
1598
  }
762
1599
  });
1600
+ return { new_template_id: res.id, title, type: payload.type, url: res.link };
1601
+ }
1602
+ });
1603
+ var applyTemplateToPageTool = defineTool({
1604
+ name: "apply_template_to_page",
1605
+ description: "Copy the _elementor_data + _elementor_page_settings of a SOURCE template (or page) onto a TARGET page on the same site. Backs up the target first. Use to apply a section/page template to an existing draft.",
1606
+ inputSchema: z4.object({
1607
+ site_id: z4.string().optional(),
1608
+ source_id: z4.number().int().positive().describe("Source post id: a template or a page."),
1609
+ target_page_id: z4.number().int().positive(),
1610
+ backup_to_file: z4.boolean().default(false),
1611
+ confirmation: z4.string().optional()
1612
+ }),
1613
+ outputSchema: z4.object({
1614
+ mode: z4.enum(["dry_run", "applied"]),
1615
+ target_page_id: z4.number(),
1616
+ source_id: z4.number(),
1617
+ confirmation_token: z4.string().optional(),
1618
+ expires_in_seconds: z4.number().optional(),
1619
+ backup_meta_key: z4.string().optional(),
1620
+ backup_file: z4.string().optional(),
1621
+ css_flush: z4.string().optional()
1622
+ }),
1623
+ annotations: { destructiveHint: true, idempotentHint: false, openWorldHint: true },
1624
+ async handler(input) {
1625
+ const { issueConfirmation: issueConfirmation2, consumeConfirmation: consumeConfirmation2 } = await Promise.resolve().then(() => (init_confirmation(), confirmation_exports));
1626
+ const { POLICIES: POLICIES2 } = await Promise.resolve().then(() => (init_policies(), policies_exports));
1627
+ const { fullBackup: fullBackup2 } = await Promise.resolve().then(() => (init_backup(), backup_exports));
1628
+ const { flushCSS: flushCSS2 } = await Promise.resolve().then(() => (init_css_flush(), css_flush_exports));
1629
+ if (!input.confirmation) {
1630
+ const token = issueConfirmation2("apply_template_to_page", { src: input.source_id, tgt: input.target_page_id, site: input.site_id }, POLICIES2.CONFIRMATION_TTL_SECONDS);
1631
+ return {
1632
+ mode: "dry_run",
1633
+ target_page_id: input.target_page_id,
1634
+ source_id: input.source_id,
1635
+ confirmation_token: token,
1636
+ expires_in_seconds: POLICIES2.CONFIRMATION_TTL_SECONDS
1637
+ };
1638
+ }
1639
+ const conf = consumeConfirmation2(input.confirmation, "apply_template_to_page");
1640
+ if (!conf) throw new Error("Invalid or expired confirmation token");
1641
+ let src_meta;
1642
+ try {
1643
+ const src = await wpRequest(`/wp/v2/elementor_library/${input.source_id}?context=edit`, { siteId: input.site_id });
1644
+ src_meta = src.meta;
1645
+ } catch {
1646
+ const src = await wpRequest(`/wp/v2/pages/${input.source_id}?context=edit`, { siteId: input.site_id });
1647
+ src_meta = src.meta;
1648
+ }
1649
+ const data_raw = src_meta?._elementor_data ?? "[]";
1650
+ const settings_raw = src_meta?._elementor_page_settings ?? "{}";
1651
+ const backup = await fullBackup2(input.site_id, input.target_page_id, { to_file: input.backup_to_file });
1652
+ await wpRequest(`/wp/v2/pages/${input.target_page_id}`, {
1653
+ siteId: input.site_id,
1654
+ method: "PUT",
1655
+ body: {
1656
+ meta: {
1657
+ _elementor_data: data_raw,
1658
+ _elementor_page_settings: parseSettingsForRest2(settings_raw),
1659
+ _elementor_edit_mode: "builder"
1660
+ }
1661
+ }
1662
+ });
1663
+ const flush = await flushCSS2(input.site_id, input.target_page_id);
763
1664
  return {
764
- new_template_id: res.id,
765
- title,
766
- type: payload.type,
767
- url: res.link
1665
+ mode: "applied",
1666
+ target_page_id: input.target_page_id,
1667
+ source_id: input.source_id,
1668
+ backup_meta_key: backup.meta_key,
1669
+ backup_file: backup.file_path,
1670
+ css_flush: flush.method
768
1671
  };
769
1672
  }
770
1673
  });
1674
+ function parseSettingsForRest2(raw) {
1675
+ if (typeof raw === "object" && raw !== null) return raw;
1676
+ if (typeof raw === "string") {
1677
+ if (!raw.trim()) return {};
1678
+ try {
1679
+ return JSON.parse(raw);
1680
+ } catch {
1681
+ return {};
1682
+ }
1683
+ }
1684
+ return {};
1685
+ }
771
1686
 
772
- // src/tools/updates.ts
1687
+ // src/tools/wpcli.ts
773
1688
  import { z as z5 } from "zod";
1689
+ init_config();
1690
+ init_ssh_wpcli();
1691
+ init_policies();
1692
+ init_confirmation();
1693
+ var wpCliRunTool = defineTool({
1694
+ name: "wp_cli_run",
1695
+ description: "Execute an arbitrary wp-cli command on a site via SSH. The `wp` prefix and `--path` are added automatically \u2014 pass only the args (e.g. 'post list --post_type=page'). Destructive commands (delete, drop, search-replace without --dry-run, plugin deactivate/uninstall) require a two-call confirmation flow.",
1696
+ inputSchema: z5.object({
1697
+ site_id: z5.string().optional(),
1698
+ args: z5.string().min(1).describe("WP-CLI args without leading 'wp', e.g. 'post list --post_type=page'"),
1699
+ timeout_ms: z5.number().int().min(1e3).max(3e5).default(6e4),
1700
+ confirmation: z5.string().optional()
1701
+ }),
1702
+ outputSchema: z5.object({
1703
+ mode: z5.enum(["dry_run_destructive", "executed"]),
1704
+ command: z5.string(),
1705
+ stdout: z5.string().optional(),
1706
+ stderr: z5.string().optional(),
1707
+ exit_code: z5.number().optional(),
1708
+ duration_ms: z5.number().optional(),
1709
+ destructive_pattern_detected: z5.boolean().optional(),
1710
+ confirmation_token: z5.string().optional(),
1711
+ expires_in_seconds: z5.number().optional()
1712
+ }),
1713
+ annotations: { destructiveHint: true, idempotentHint: false, openWorldHint: true },
1714
+ async handler(input) {
1715
+ const forbidden = isForbiddenWpCli(input.args);
1716
+ if (forbidden.forbidden) {
1717
+ throw new Error(`Forbidden wp-cli command. ${forbidden.reason}`);
1718
+ }
1719
+ const isDestructive = isDestructiveWpCli(input.args);
1720
+ if (isDestructive && !input.confirmation) {
1721
+ const token = issueConfirmation("wp_cli_run", { site_id: input.site_id, args: input.args }, POLICIES.CONFIRMATION_TTL_SECONDS);
1722
+ return {
1723
+ mode: "dry_run_destructive",
1724
+ command: `wp <path> ${input.args}`,
1725
+ destructive_pattern_detected: true,
1726
+ confirmation_token: token,
1727
+ expires_in_seconds: POLICIES.CONFIRMATION_TTL_SECONDS
1728
+ };
1729
+ }
1730
+ if (isDestructive && input.confirmation) {
1731
+ const conf = consumeConfirmation(input.confirmation, "wp_cli_run");
1732
+ if (!conf) throw new Error("Invalid or expired confirmation token");
1733
+ const o = conf.payload;
1734
+ if (o.args !== input.args || o.site_id !== input.site_id) {
1735
+ throw new Error("Confirmation parameters don't match the original dry-run");
1736
+ }
1737
+ }
1738
+ const site = getSite(input.site_id);
1739
+ const r = await sshWpCli(site, input.args, { timeout_ms: input.timeout_ms });
1740
+ return {
1741
+ mode: "executed",
1742
+ command: r.command,
1743
+ stdout: r.stdout,
1744
+ stderr: r.stderr,
1745
+ exit_code: r.exitCode,
1746
+ duration_ms: r.duration_ms,
1747
+ destructive_pattern_detected: isDestructive
1748
+ };
1749
+ }
1750
+ });
1751
+ var wpSearchReplaceTool = defineTool({
1752
+ name: "wp_search_replace",
1753
+ description: "Run `wp search-replace` against wp_postmeta (default) \u2014 the standard agency way to update Elementor text content. ALWAYS dry-run first; the apply call requires a confirmation token. Includes --precise --all-tables-with-prefix by default if you specify table='all'.",
1754
+ inputSchema: z5.object({
1755
+ site_id: z5.string().optional(),
1756
+ find: z5.string().min(1),
1757
+ replace: z5.string(),
1758
+ table: z5.string().default("wp_postmeta"),
1759
+ include_columns: z5.string().optional().describe("e.g. 'meta_value'. Default: meta_value when table=wp_postmeta."),
1760
+ precise: z5.boolean().default(true),
1761
+ confirmation: z5.string().optional()
1762
+ }),
1763
+ outputSchema: z5.object({
1764
+ mode: z5.enum(["dry_run", "applied"]),
1765
+ command: z5.string(),
1766
+ stdout: z5.string().optional(),
1767
+ stderr: z5.string().optional(),
1768
+ exit_code: z5.number().optional(),
1769
+ replacement_count: z5.number().optional(),
1770
+ confirmation_token: z5.string().optional(),
1771
+ expires_in_seconds: z5.number().optional()
1772
+ }),
1773
+ annotations: { destructiveHint: true, idempotentHint: false, openWorldHint: true },
1774
+ async handler(input) {
1775
+ const includeCols = input.include_columns ?? (input.table === "wp_postmeta" ? "meta_value" : "");
1776
+ const baseArgs = [
1777
+ "search-replace",
1778
+ `'${input.find.replace(/'/g, "'\\''")}'`,
1779
+ `'${input.replace.replace(/'/g, "'\\''")}'`,
1780
+ input.table,
1781
+ includeCols ? `--include-columns=${includeCols}` : "",
1782
+ input.precise ? "--precise" : ""
1783
+ ].filter(Boolean).join(" ");
1784
+ const site = getSite(input.site_id);
1785
+ if (!input.confirmation) {
1786
+ const dry = await sshWpCli(site, baseArgs + " --dry-run");
1787
+ const m2 = dry.stdout.match(/Success: (\d+) replacement/);
1788
+ const count = m2 ? parseInt(m2[1], 10) : 0;
1789
+ if (count === 0) {
1790
+ return { mode: "dry_run", command: dry.command, stdout: dry.stdout, exit_code: dry.exitCode, replacement_count: 0 };
1791
+ }
1792
+ const token = issueConfirmation("wp_search_replace", { args: baseArgs, site_id: input.site_id }, POLICIES.CONFIRMATION_TTL_SECONDS);
1793
+ return {
1794
+ mode: "dry_run",
1795
+ command: dry.command,
1796
+ stdout: dry.stdout,
1797
+ replacement_count: count,
1798
+ confirmation_token: token,
1799
+ expires_in_seconds: POLICIES.CONFIRMATION_TTL_SECONDS
1800
+ };
1801
+ }
1802
+ const conf = consumeConfirmation(input.confirmation, "wp_search_replace");
1803
+ if (!conf) throw new Error("Invalid or expired confirmation token");
1804
+ const o = conf.payload;
1805
+ if (o.args !== baseArgs || o.site_id !== input.site_id) throw new Error("Confirmation params mismatch");
1806
+ const r = await sshWpCli(site, baseArgs);
1807
+ const m = r.stdout.match(/Success: (\d+) replacement/);
1808
+ return {
1809
+ mode: "applied",
1810
+ command: r.command,
1811
+ stdout: r.stdout,
1812
+ stderr: r.stderr,
1813
+ exit_code: r.exitCode,
1814
+ replacement_count: m ? parseInt(m[1], 10) : 0
1815
+ };
1816
+ }
1817
+ });
1818
+ var wpElementorFlushCssTool = defineTool({
1819
+ name: "wp_elementor_flush_css",
1820
+ description: "Flush Elementor's CSS cache on a site using the 3-level fallback strategy (REST endpoint \u2192 wp-cli native \u2192 option/meta delete). Always call after writing _elementor_data programmatically.",
1821
+ inputSchema: z5.object({
1822
+ site_id: z5.string().optional(),
1823
+ page_id: z5.number().int().positive().optional().describe("Optional: flush only this page's cache. If omitted, flushes site-wide.")
1824
+ }),
1825
+ outputSchema: z5.object({
1826
+ method: z5.enum(["rest", "wp-cli", "option-delete", "resave", "none"]),
1827
+ details: z5.string().optional()
1828
+ }),
1829
+ annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: true },
1830
+ async handler(input) {
1831
+ const { flushCSS: flushCSS2 } = await Promise.resolve().then(() => (init_css_flush(), css_flush_exports));
1832
+ return flushCSS2(input.site_id, input.page_id);
1833
+ }
1834
+ });
1835
+ var wpPluginListTool = defineTool({
1836
+ name: "wp_plugin_list",
1837
+ description: "List installed plugins on a site with name, version, status (active/inactive), and update_version (if outdated). Uses WP-CLI for accurate version data including update_version.",
1838
+ inputSchema: z5.object({
1839
+ site_id: z5.string().optional(),
1840
+ only_outdated: z5.boolean().default(false)
1841
+ }),
1842
+ outputSchema: z5.object({
1843
+ total: z5.number(),
1844
+ plugins: z5.array(z5.object({
1845
+ name: z5.string(),
1846
+ status: z5.string(),
1847
+ version: z5.string(),
1848
+ update: z5.string().optional(),
1849
+ update_version: z5.string().optional()
1850
+ }))
1851
+ }),
1852
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
1853
+ async handler(input) {
1854
+ const site = getSite(input.site_id);
1855
+ const r = await sshWpCli(site, "plugin list --format=json");
1856
+ if (r.exitCode !== 0) throw new Error(`wp plugin list failed: ${r.stderr}`);
1857
+ const arr = JSON.parse(r.stdout);
1858
+ const filtered = input.only_outdated ? arr.filter((p) => p.update === "available") : arr;
1859
+ return { total: filtered.length, plugins: filtered };
1860
+ }
1861
+ });
1862
+ var wpPluginUpdateTool = defineTool({
1863
+ name: "wp_plugin_update",
1864
+ description: "Update one or more plugins on a site to their latest version. Requires confirmation token (uses wp-cli).",
1865
+ inputSchema: z5.object({
1866
+ site_id: z5.string().optional(),
1867
+ plugins: z5.array(z5.string()).min(1).describe("Plugin slugs to update, e.g. ['elementor', 'elementor-pro']. Use 'all' for everything outdated."),
1868
+ confirmation: z5.string().optional()
1869
+ }),
1870
+ outputSchema: z5.object({
1871
+ mode: z5.enum(["dry_run", "applied"]),
1872
+ plugins: z5.array(z5.string()),
1873
+ command: z5.string().optional(),
1874
+ stdout: z5.string().optional(),
1875
+ stderr: z5.string().optional(),
1876
+ exit_code: z5.number().optional(),
1877
+ confirmation_token: z5.string().optional(),
1878
+ expires_in_seconds: z5.number().optional()
1879
+ }),
1880
+ annotations: { destructiveHint: true, idempotentHint: false, openWorldHint: true },
1881
+ async handler(input) {
1882
+ if (!input.confirmation) {
1883
+ const token = issueConfirmation("wp_plugin_update", { site_id: input.site_id, plugins: input.plugins }, POLICIES.CONFIRMATION_TTL_SECONDS);
1884
+ return {
1885
+ mode: "dry_run",
1886
+ plugins: input.plugins,
1887
+ confirmation_token: token,
1888
+ expires_in_seconds: POLICIES.CONFIRMATION_TTL_SECONDS
1889
+ };
1890
+ }
1891
+ const conf = consumeConfirmation(input.confirmation, "wp_plugin_update");
1892
+ if (!conf) throw new Error("Invalid or expired confirmation token");
1893
+ const site = getSite(input.site_id);
1894
+ const target = input.plugins.includes("all") ? "--all" : input.plugins.map((p) => `'${p}'`).join(" ");
1895
+ const r = await sshWpCli(site, `plugin update ${target}`, { timeout_ms: 18e4 });
1896
+ return {
1897
+ mode: "applied",
1898
+ plugins: input.plugins,
1899
+ command: r.command,
1900
+ stdout: r.stdout,
1901
+ stderr: r.stderr,
1902
+ exit_code: r.exitCode
1903
+ };
1904
+ }
1905
+ });
1906
+
1907
+ // src/tools/visual.ts
1908
+ import { z as z6 } from "zod";
1909
+
1910
+ // src/transport/screenshot.ts
1911
+ init_logger();
1912
+ import { spawn as spawn2 } from "child_process";
1913
+ import { existsSync as existsSync2 } from "fs";
1914
+ import { tmpdir as tmpdir2 } from "os";
1915
+ import { join as join2 } from "path";
1916
+ import { randomBytes as randomBytes2 } from "crypto";
1917
+ var CHROME_PATHS = [
1918
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
1919
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
1920
+ "/usr/bin/google-chrome",
1921
+ "/usr/bin/chromium",
1922
+ "/usr/bin/chromium-browser"
1923
+ ];
1924
+ function findChrome() {
1925
+ if (process.env.ELEMENTOR_MCP_CHROME) {
1926
+ return existsSync2(process.env.ELEMENTOR_MCP_CHROME) ? process.env.ELEMENTOR_MCP_CHROME : null;
1927
+ }
1928
+ for (const p of CHROME_PATHS) {
1929
+ if (existsSync2(p)) return p;
1930
+ }
1931
+ return null;
1932
+ }
1933
+ async function screenshotUrl(url, opts = {}) {
1934
+ const chrome = findChrome();
1935
+ if (!chrome) {
1936
+ throw new Error(
1937
+ "Could not locate a Chrome/Chromium binary for screenshots. Set ELEMENTOR_MCP_CHROME to its path or install Chrome at one of: " + CHROME_PATHS.join(", ")
1938
+ );
1939
+ }
1940
+ const filename = `elementor-mcp-${randomBytes2(4).toString("hex")}-${Date.now()}.png`;
1941
+ const path = join2(tmpdir2(), filename);
1942
+ const args = [
1943
+ "--headless=new",
1944
+ "--disable-gpu",
1945
+ "--no-sandbox",
1946
+ "--hide-scrollbars",
1947
+ `--window-size=${opts.width ?? 1440},${opts.height ?? 900}`,
1948
+ `--screenshot=${path}`,
1949
+ ...opts.full_page ? ["--virtual-time-budget=10000"] : [],
1950
+ "--default-background-color=00000000",
1951
+ url
1952
+ ];
1953
+ const timeout = opts.timeout_ms ?? 6e4;
1954
+ logger.debug({ url, chrome }, "screenshot");
1955
+ await new Promise((resolve3, reject) => {
1956
+ const child = spawn2(chrome, args, { stdio: ["ignore", "ignore", "pipe"] });
1957
+ let stderr = "";
1958
+ const killer = setTimeout(() => {
1959
+ child.kill("SIGKILL");
1960
+ reject(new Error(`Screenshot timed out after ${timeout}ms`));
1961
+ }, timeout);
1962
+ child.stderr.on("data", (b) => {
1963
+ stderr += b.toString();
1964
+ });
1965
+ child.on("close", (code) => {
1966
+ clearTimeout(killer);
1967
+ if (code !== 0) reject(new Error(`Chrome exited ${code}: ${stderr.slice(0, 300)}`));
1968
+ else resolve3();
1969
+ });
1970
+ child.on("error", (e) => {
1971
+ clearTimeout(killer);
1972
+ reject(e);
1973
+ });
1974
+ });
1975
+ if (!existsSync2(path)) throw new Error(`Screenshot file not created: ${path}`);
1976
+ const fs = await import("fs");
1977
+ const stat = fs.statSync(path);
1978
+ return { path, bytes: stat.size };
1979
+ }
1980
+
1981
+ // src/tools/visual.ts
1982
+ init_wp_rest();
1983
+ import { readFileSync as readFileSync3 } from "fs";
1984
+ import { createHash } from "crypto";
1985
+ var screenshotPageTool = defineTool({
1986
+ name: "screenshot_page",
1987
+ description: "Capture a PNG screenshot of a page's frontend (visitor-facing URL). Requires a Chrome/Chromium binary on the host. Returns the local file path so the LLM can analyse it or compare against another shot.",
1988
+ inputSchema: z6.object({
1989
+ site_id: z6.string().optional(),
1990
+ page_id: z6.number().int().positive().optional(),
1991
+ url: z6.string().url().optional().describe("Alternative to page_id: hit any URL directly."),
1992
+ width: z6.number().int().min(320).max(3840).default(1440),
1993
+ height: z6.number().int().min(240).max(2400).default(900),
1994
+ full_page: z6.boolean().default(false)
1995
+ }),
1996
+ outputSchema: z6.object({
1997
+ url: z6.string(),
1998
+ file_path: z6.string(),
1999
+ bytes: z6.number(),
2000
+ sha256: z6.string(),
2001
+ width: z6.number(),
2002
+ height: z6.number()
2003
+ }),
2004
+ annotations: { readOnlyHint: true, idempotentHint: false, openWorldHint: true },
2005
+ async handler(input) {
2006
+ let target = input.url;
2007
+ if (!target) {
2008
+ if (!input.page_id) throw new Error("Provide either 'url' or 'page_id'.");
2009
+ const page = await wpRequest(`/wp/v2/pages/${input.page_id}?_fields=link`, { siteId: input.site_id });
2010
+ target = page.link;
2011
+ }
2012
+ const shot = await screenshotUrl(target, { width: input.width, height: input.height, full_page: input.full_page });
2013
+ const hash = createHash("sha256").update(readFileSync3(shot.path)).digest("hex");
2014
+ return {
2015
+ url: target,
2016
+ file_path: shot.path,
2017
+ bytes: shot.bytes,
2018
+ sha256: hash,
2019
+ width: input.width,
2020
+ height: input.height
2021
+ };
2022
+ }
2023
+ });
2024
+ var compareScreenshotsTool = defineTool({
2025
+ name: "compare_screenshots",
2026
+ description: "Compare two screenshot files via SHA-256 hash equality and size delta. Quick way to spot whether a page changed visually after an edit. For pixel diffs, use a dedicated tool externally.",
2027
+ inputSchema: z6.object({
2028
+ before_path: z6.string(),
2029
+ after_path: z6.string()
2030
+ }),
2031
+ outputSchema: z6.object({
2032
+ identical: z6.boolean(),
2033
+ before_bytes: z6.number(),
2034
+ after_bytes: z6.number(),
2035
+ delta_bytes: z6.number(),
2036
+ before_sha256: z6.string(),
2037
+ after_sha256: z6.string()
2038
+ }),
2039
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
2040
+ async handler(input) {
2041
+ const a = readFileSync3(input.before_path);
2042
+ const b = readFileSync3(input.after_path);
2043
+ const ah = createHash("sha256").update(a).digest("hex");
2044
+ const bh = createHash("sha256").update(b).digest("hex");
2045
+ return {
2046
+ identical: ah === bh,
2047
+ before_bytes: a.length,
2048
+ after_bytes: b.length,
2049
+ delta_bytes: b.length - a.length,
2050
+ before_sha256: ah,
2051
+ after_sha256: bh
2052
+ };
2053
+ }
2054
+ });
2055
+
2056
+ // src/tools/updates.ts
2057
+ import { z as z7 } from "zod";
2058
+ init_wp_rest();
2059
+ init_config();
774
2060
  async function fetchLatestElementor() {
775
- const free = await fetch("https://api.wordpress.org/plugins/info/1.0/elementor.json").then((r) => r.json()).catch(() => null);
776
- return { free: free?.version ?? "unknown" };
2061
+ try {
2062
+ const free = await fetch("https://api.wordpress.org/plugins/info/1.0/elementor.json", {
2063
+ headers: { "User-Agent": "elementor-mcp-agent" }
2064
+ }).then((r) => r.json());
2065
+ return { free: free.version ?? "unknown" };
2066
+ } catch {
2067
+ return { free: "unknown" };
2068
+ }
777
2069
  }
778
2070
  var checkElementorVersionsTool = defineTool({
779
2071
  name: "check_elementor_versions",
780
- description: "For every configured site, fetch the installed Elementor / Elementor Pro version and compare against the latest available on wordpress.org. Returns a per-site row with 'outdated' flag.",
781
- inputSchema: z5.object({
782
- site_ids: z5.array(z5.string()).optional().describe("Subset of sites to check. Defaults to all.")
2072
+ description: "Fleet-wide Elementor version audit. For every site, fetches installed Elementor/Pro versions and compares against wordpress.org latest. Flags outdated installs.",
2073
+ inputSchema: z7.object({
2074
+ site_ids: z7.array(z7.string()).optional()
783
2075
  }),
784
- outputSchema: z5.object({
785
- checked: z5.number(),
786
- latest_elementor_free: z5.string(),
787
- sites: z5.array(
788
- z5.object({
789
- site_id: z5.string(),
790
- url: z5.string(),
791
- elementor_version: z5.string().optional(),
792
- elementor_pro_version: z5.string().optional(),
793
- outdated_free: z5.boolean(),
794
- error: z5.string().optional()
795
- })
796
- )
2076
+ outputSchema: z7.object({
2077
+ checked: z7.number(),
2078
+ latest_elementor_free: z7.string(),
2079
+ sites: z7.array(z7.object({
2080
+ site_id: z7.string(),
2081
+ url: z7.string(),
2082
+ elementor_version: z7.string().optional(),
2083
+ elementor_pro_version: z7.string().optional(),
2084
+ outdated_free: z7.boolean(),
2085
+ error: z7.string().optional()
2086
+ }))
797
2087
  }),
798
2088
  annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
799
2089
  async handler(input) {
@@ -807,8 +2097,7 @@ var checkElementorVersionsTool = defineTool({
807
2097
  let elementor_version;
808
2098
  let elementor_pro_version;
809
2099
  for (const p of plugins) {
810
- if (p.plugin.startsWith("elementor/") && p.plugin.endsWith("/elementor.php"))
811
- elementor_version = p.version;
2100
+ if (p.plugin.startsWith("elementor/") && p.plugin.endsWith("/elementor.php")) elementor_version = p.version;
812
2101
  if (p.plugin.startsWith("elementor-pro/")) elementor_pro_version = p.version;
813
2102
  }
814
2103
  rows.push({
@@ -819,43 +2108,58 @@ var checkElementorVersionsTool = defineTool({
819
2108
  outdated_free: !!elementor_version && elementor_version !== latest.free && latest.free !== "unknown"
820
2109
  });
821
2110
  } catch (e) {
822
- rows.push({
823
- site_id: site.id,
824
- url: site.url,
825
- outdated_free: false,
826
- error: e.message
827
- });
2111
+ rows.push({ site_id: site.id, url: site.url, outdated_free: false, error: e.message });
828
2112
  }
829
2113
  }
830
- return {
831
- checked: rows.length,
832
- latest_elementor_free: latest.free,
833
- sites: rows
834
- };
2114
+ return { checked: rows.length, latest_elementor_free: latest.free, sites: rows };
835
2115
  }
836
2116
  });
837
2117
 
838
2118
  // src/tools/index.ts
839
2119
  var tools = [
2120
+ // Sites & health
840
2121
  listSitesTool,
841
2122
  pingSiteTool,
2123
+ siteHealthTool,
2124
+ // Pages
842
2125
  listElementorPagesTool,
843
2126
  readPageElementorTool,
2127
+ listWidgetsInPageTool,
2128
+ listGlobalWidgetsTool,
2129
+ preflightCheckTool,
844
2130
  findReplaceTool,
2131
+ listElementorBackupsTool,
2132
+ restoreElementorBackupTool,
2133
+ duplicateElementorPageTool,
2134
+ // Templates
845
2135
  listTemplatesTool,
846
2136
  exportTemplateTool,
847
2137
  importTemplateTool,
2138
+ applyTemplateToPageTool,
2139
+ // WP-CLI escape
2140
+ wpCliRunTool,
2141
+ wpSearchReplaceTool,
2142
+ wpElementorFlushCssTool,
2143
+ wpPluginListTool,
2144
+ wpPluginUpdateTool,
2145
+ // Visual
2146
+ screenshotPageTool,
2147
+ compareScreenshotsTool,
2148
+ // Versions
848
2149
  checkElementorVersionsTool
849
2150
  ];
850
2151
 
2152
+ // src/server.ts
2153
+ init_logger();
2154
+
851
2155
  // src/resources/index.ts
852
- import { readFileSync as readFileSync2, existsSync, readdirSync } from "fs";
853
- import { dirname, resolve, join, basename } from "path";
2156
+ import { readFileSync as readFileSync4, existsSync as existsSync3, readdirSync } from "fs";
2157
+ import { dirname, resolve, join as join3, basename } from "path";
854
2158
  import { fileURLToPath } from "url";
855
2159
  var __dirname = dirname(fileURLToPath(import.meta.url));
856
2160
  var DOCS_DIR = resolve(__dirname, "../resources/elementor-docs");
857
2161
  async function listResources() {
858
- if (!existsSync(DOCS_DIR)) return [];
2162
+ if (!existsSync3(DOCS_DIR)) return [];
859
2163
  const files = readdirSync(DOCS_DIR).filter((f) => f.endsWith(".md"));
860
2164
  return files.map((f) => ({
861
2165
  uri: `elementor-docs://${f}`,
@@ -869,14 +2173,14 @@ async function readResource(uri) {
869
2173
  throw new Error(`Unknown resource URI: ${uri}`);
870
2174
  }
871
2175
  const filename = uri.replace("elementor-docs://", "");
872
- const path = join(DOCS_DIR, filename);
873
- if (!existsSync(path)) throw new Error(`Resource not found: ${filename}`);
2176
+ const path = join3(DOCS_DIR, filename);
2177
+ if (!existsSync3(path)) throw new Error(`Resource not found: ${filename}`);
874
2178
  return {
875
2179
  contents: [
876
2180
  {
877
2181
  uri,
878
2182
  mimeType: "text/markdown",
879
- text: readFileSync2(path, "utf8")
2183
+ text: readFileSync4(path, "utf8")
880
2184
  }
881
2185
  ]
882
2186
  };
@@ -884,7 +2188,7 @@ async function readResource(uri) {
884
2188
 
885
2189
  // src/server.ts
886
2190
  var __dirname2 = dirname2(fileURLToPath2(import.meta.url));
887
- var pkg = JSON.parse(readFileSync3(resolve2(__dirname2, "../package.json"), "utf8"));
2191
+ var pkg = JSON.parse(readFileSync5(resolve2(__dirname2, "../package.json"), "utf8"));
888
2192
  function toMcpInputSchema(zod) {
889
2193
  return zodToJsonSchema(zod, { target: "jsonSchema7", $refStrategy: "none" });
890
2194
  }