elementor-mcp-agent 0.1.0 → 1.1.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 writeData2 = await sshWpCli(site, `post meta update ${postId} _elementor_data ${shellQuote(data_value)}`, { timeout_ms: 3e4 });
452
+ if (writeData2.exitCode !== 0) throw new Error(`Restore write failed: ${writeData2.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) {
@@ -372,6 +854,12 @@ function* walkElements(data, path = [], depth = 0) {
372
854
  }
373
855
  }
374
856
  }
857
+ function findElementById(data, id) {
858
+ for (const { element } of walkElements(data)) {
859
+ if (element.id === id) return element;
860
+ }
861
+ return null;
862
+ }
375
863
  function findReplaceInWidgets(data, find, replace, options = {}) {
376
864
  let count = 0;
377
865
  const flags = options.caseSensitive ? "g" : "gi";
@@ -422,59 +910,101 @@ function summarize(data) {
422
910
  return { totalElements, sections, containers, columns, widgets, byWidgetType, maxDepth };
423
911
  }
424
912
 
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}`, {
913
+ // src/tools/pages.ts
914
+ init_backup();
915
+ init_css_flush();
916
+
917
+ // src/elementor/validator.ts
918
+ init_policies();
919
+ function validateElementorData(input) {
920
+ const errors = [];
921
+ const warnings = [];
922
+ if (typeof input === "string" && input.length > POLICIES.MAX_ELEMENTOR_DATA_BYTES) {
923
+ errors.push(`_elementor_data exceeds ${POLICIES.MAX_ELEMENTOR_DATA_BYTES} bytes (got ${input.length})`);
924
+ return { valid: false, errors, warnings };
925
+ }
926
+ let data;
927
+ try {
928
+ data = parseElementorData(input);
929
+ } catch (e) {
930
+ errors.push(`JSON parse failed: ${e.message}`);
931
+ return { valid: false, errors, warnings };
932
+ }
933
+ function validateElement(el, depth, path) {
934
+ const here = [...path, el.id ?? "?"];
935
+ if (typeof el.id !== "string" || el.id.length === 0) {
936
+ errors.push(`element at ${here.join(".")}: missing or empty id`);
937
+ }
938
+ if (!["section", "container", "column", "widget"].includes(el.elType)) {
939
+ errors.push(`element ${el.id}: unknown elType "${el.elType}"`);
940
+ }
941
+ if (el.elType === "widget" && (typeof el.widgetType !== "string" || el.widgetType.length === 0)) {
942
+ errors.push(`widget ${el.id}: missing widgetType`);
943
+ }
944
+ if (el.settings === null || el.settings === void 0 || typeof el.settings !== "object" || Array.isArray(el.settings)) {
945
+ errors.push(`element ${el.id}: settings must be an object (got ${typeof el.settings})`);
946
+ }
947
+ if (depth > 20) warnings.push(`element ${el.id}: depth ${depth} is unusually deep`);
948
+ if (Array.isArray(el.elements)) {
949
+ for (const child of el.elements) validateElement(child, depth + 1, here);
950
+ }
951
+ }
952
+ if (!Array.isArray(data)) {
953
+ errors.push("Top-level must be an array");
954
+ return { valid: false, errors, warnings };
955
+ }
956
+ for (const el of data) validateElement(el, 0, []);
957
+ return { valid: errors.length === 0, errors, warnings };
958
+ }
959
+
960
+ // src/elementor/globals.ts
961
+ init_wp_rest();
962
+ async function listGlobalWidgets(siteId) {
963
+ const items = await wpRequest("/wp/v2/elementor_library", {
432
964
  siteId,
433
- method: "PUT",
434
- body: { meta: { [meta_key]: raw } }
965
+ query: {
966
+ context: "edit",
967
+ per_page: 100,
968
+ _fields: "id,title,meta"
969
+ }
970
+ });
971
+ const widgetsOnly = items.filter((t) => t.meta?._elementor_template_type === "widget");
972
+ return widgetsOnly.map((t) => {
973
+ const data = parseElementorData(t.meta?._elementor_data ?? "[]");
974
+ let widget_type;
975
+ for (const { element } of walkElements(data)) {
976
+ if (element.elType === "widget") {
977
+ widget_type = element.widgetType;
978
+ break;
979
+ }
980
+ }
981
+ return {
982
+ template_id: t.id,
983
+ title: t.title.rendered,
984
+ widget_type
985
+ };
435
986
  });
436
- return { meta_key, size_bytes: raw.length };
437
987
  }
438
- async function flushElementorCSS(siteId, postId) {
988
+ function findGlobalReferences(data) {
989
+ const out = [];
439
990
  try {
440
- await wpRequest(`/elementor/v1/css?id=${postId}&action=regenerate`, { siteId, method: "POST" });
441
- return { method: "rest" };
991
+ for (const { element } of walkElements(parseElementorData(data))) {
992
+ if (element.elType === "widget" && element.widgetType === "global") {
993
+ const tid = element.settings.template_id ?? element.settings.templateID;
994
+ if (typeof tid === "number") out.push({ widget_id: element.id, template_id: tid });
995
+ }
996
+ }
442
997
  } 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
998
  }
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;
999
+ return out;
472
1000
  }
473
1001
 
474
1002
  // src/tools/pages.ts
1003
+ init_confirmation();
1004
+ init_policies();
475
1005
  var listElementorPagesTool = defineTool({
476
1006
  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.",
1007
+ description: "List pages built with Elementor (have _elementor_edit_mode = 'builder'). Returns id, title, slug, status, modified date.",
478
1008
  inputSchema: z3.object({
479
1009
  site_id: z3.string().optional(),
480
1010
  per_page: z3.number().int().min(1).max(100).default(25),
@@ -482,16 +1012,14 @@ var listElementorPagesTool = defineTool({
482
1012
  }),
483
1013
  outputSchema: z3.object({
484
1014
  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
- )
1015
+ pages: z3.array(z3.object({
1016
+ id: z3.number(),
1017
+ title: z3.string(),
1018
+ slug: z3.string(),
1019
+ status: z3.string(),
1020
+ link: z3.string(),
1021
+ modified: z3.string()
1022
+ }))
495
1023
  }),
496
1024
  annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
497
1025
  async handler(input) {
@@ -521,11 +1049,11 @@ var listElementorPagesTool = defineTool({
521
1049
  });
522
1050
  var readPageElementorTool = defineTool({
523
1051
  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.",
1052
+ description: "Fetch a page's Elementor data structure summary. With verbose=true returns the full parsed tree (potentially MBs).",
525
1053
  inputSchema: z3.object({
526
1054
  site_id: z3.string().optional(),
527
1055
  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).")
1056
+ verbose: z3.boolean().default(false)
529
1057
  }),
530
1058
  outputSchema: z3.object({
531
1059
  page_id: z3.number(),
@@ -539,6 +1067,7 @@ var readPageElementorTool = defineTool({
539
1067
  maxDepth: z3.number(),
540
1068
  byWidgetType: z3.record(z3.number())
541
1069
  }),
1070
+ global_references: z3.array(z3.object({ widget_id: z3.string(), template_id: z3.number() })),
542
1071
  data: z3.array(z3.any()).optional()
543
1072
  }),
544
1073
  annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
@@ -549,113 +1078,426 @@ var readPageElementorTool = defineTool({
549
1078
  );
550
1079
  const raw = page.meta?._elementor_data ?? "[]";
551
1080
  const data = parseElementorData(raw);
552
- const summary = summarize(data);
553
1081
  return {
554
1082
  page_id: page.id,
555
1083
  title: page.title.rendered,
556
- summary,
1084
+ summary: summarize(data),
1085
+ global_references: findGlobalReferences(raw),
557
1086
  data: input.verbose ? data : void 0
558
1087
  };
559
1088
  }
560
1089
  });
1090
+ var listWidgetsInPageTool = defineTool({
1091
+ name: "list_widgets_in_page",
1092
+ 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).",
1093
+ inputSchema: z3.object({
1094
+ site_id: z3.string().optional(),
1095
+ page_id: z3.number().int().positive(),
1096
+ widget_type: z3.string().optional()
1097
+ }),
1098
+ outputSchema: z3.object({
1099
+ page_id: z3.number(),
1100
+ total: z3.number(),
1101
+ widgets: z3.array(z3.object({
1102
+ widget_id: z3.string(),
1103
+ widget_type: z3.string(),
1104
+ path: z3.array(z3.string()),
1105
+ excerpt: z3.string().optional()
1106
+ }))
1107
+ }),
1108
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
1109
+ async handler(input) {
1110
+ const page = await wpRequest(
1111
+ `/wp/v2/pages/${input.page_id}?context=edit&_fields=id,meta`,
1112
+ { siteId: input.site_id }
1113
+ );
1114
+ const data = parseElementorData(page.meta?._elementor_data ?? "[]");
1115
+ const widgets = [];
1116
+ for (const { element, path } of walkElements(data)) {
1117
+ if (element.elType !== "widget") continue;
1118
+ if (input.widget_type && element.widgetType !== input.widget_type) continue;
1119
+ let excerpt;
1120
+ for (const v of Object.values(element.settings ?? {})) {
1121
+ if (typeof v === "string" && v.length > 0) {
1122
+ excerpt = v.replace(/<[^>]+>/g, "").slice(0, 80);
1123
+ break;
1124
+ }
1125
+ }
1126
+ widgets.push({
1127
+ widget_id: element.id,
1128
+ widget_type: element.widgetType ?? "unknown",
1129
+ path: path.slice(0, -1),
1130
+ excerpt
1131
+ });
1132
+ }
1133
+ return { page_id: page.id, total: widgets.length, widgets };
1134
+ }
1135
+ });
1136
+ var listGlobalWidgetsTool = defineTool({
1137
+ name: "list_global_widgets",
1138
+ 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.",
1139
+ inputSchema: z3.object({ site_id: z3.string().optional() }),
1140
+ outputSchema: z3.object({
1141
+ total: z3.number(),
1142
+ globals: z3.array(z3.object({
1143
+ template_id: z3.number(),
1144
+ title: z3.string(),
1145
+ widget_type: z3.string().optional()
1146
+ }))
1147
+ }),
1148
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
1149
+ async handler(input) {
1150
+ const globals = await listGlobalWidgets(input.site_id);
1151
+ return { total: globals.length, globals };
1152
+ }
1153
+ });
1154
+ var preflightCheckTool = defineTool({
1155
+ name: "preflight_check",
1156
+ 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.",
1157
+ inputSchema: z3.object({
1158
+ site_id: z3.string().optional(),
1159
+ page_id: z3.number().int().positive()
1160
+ }),
1161
+ outputSchema: z3.object({
1162
+ safe_to_edit: z3.boolean(),
1163
+ page_id: z3.number(),
1164
+ title: z3.string(),
1165
+ issues: z3.array(z3.string()),
1166
+ warnings: z3.array(z3.string()),
1167
+ data_bytes: z3.number(),
1168
+ global_widget_references: z3.number()
1169
+ }),
1170
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
1171
+ async handler(input) {
1172
+ const issues = [];
1173
+ const warnings = [];
1174
+ let title = "";
1175
+ let data_bytes = 0;
1176
+ let global_widget_references = 0;
1177
+ try {
1178
+ const page = await wpRequest(
1179
+ `/wp/v2/pages/${input.page_id}?context=edit`,
1180
+ { siteId: input.site_id }
1181
+ );
1182
+ title = page.title.rendered;
1183
+ const raw = page.meta?._elementor_data ?? "[]";
1184
+ data_bytes = raw.length;
1185
+ if (page.meta?._elementor_edit_mode !== "builder") {
1186
+ issues.push("Page is not in Elementor builder mode");
1187
+ }
1188
+ const v = validateElementorData(raw);
1189
+ if (!v.valid) issues.push(...v.errors);
1190
+ warnings.push(...v.warnings);
1191
+ global_widget_references = findGlobalReferences(raw).length;
1192
+ if (global_widget_references > 0) {
1193
+ 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.`);
1194
+ }
1195
+ if (data_bytes > POLICIES.MAX_ELEMENTOR_DATA_BYTES) {
1196
+ issues.push(`Elementor data exceeds policy max (${POLICIES.MAX_ELEMENTOR_DATA_BYTES} bytes)`);
1197
+ }
1198
+ } catch (e) {
1199
+ issues.push(`Cannot fetch page: ${e.message}`);
1200
+ }
1201
+ return { safe_to_edit: issues.length === 0, page_id: input.page_id, title, issues, warnings, data_bytes, global_widget_references };
1202
+ }
1203
+ });
561
1204
  var findReplaceTool = defineTool({
562
1205
  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.",
1206
+ 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
1207
  inputSchema: z3.object({
565
1208
  site_id: z3.string().optional(),
566
1209
  page_id: z3.number().int().positive(),
567
1210
  find: z3.string().min(1),
568
1211
  replace: z3.string(),
569
- widget_type: z3.string().optional().describe("Restrict to one widget type, e.g. 'heading'."),
1212
+ widget_type: z3.string().optional(),
570
1213
  case_sensitive: z3.boolean().default(false),
571
- confirmation: z3.string().optional().describe("Token returned from the dry-run call.")
1214
+ backup_to_file: z3.boolean().default(false).describe("Also dump backup to /tmp/elementor-mcp-backups/"),
1215
+ confirmation: z3.string().optional()
572
1216
  }),
573
1217
  outputSchema: z3.object({
574
- mode: z3.enum(["dry_run", "applied"]),
1218
+ mode: z3.enum(["dry_run", "applied", "rolled_back"]),
575
1219
  page_id: z3.number(),
576
1220
  match_count: z3.number(),
1221
+ affected_widgets: z3.array(z3.object({
1222
+ widget_id: z3.string(),
1223
+ widget_type: z3.string(),
1224
+ before: z3.string(),
1225
+ after: z3.string()
1226
+ })).optional(),
577
1227
  confirmation_token: z3.string().optional(),
578
1228
  expires_in_seconds: z3.number().optional(),
579
1229
  backup_meta_key: z3.string().optional(),
580
- css_flush: z3.string().optional()
1230
+ backup_file: z3.string().optional(),
1231
+ css_flush: z3.string().optional(),
1232
+ validation_error: z3.string().optional()
581
1233
  }),
582
1234
  annotations: { destructiveHint: true, idempotentHint: false, openWorldHint: true },
583
1235
  async handler(input) {
584
- const cfg = loadConfig();
585
1236
  const page = await wpRequest(
586
- `/wp/v2/pages/${input.page_id}?context=edit`,
1237
+ `/wp/v2/pages/${input.page_id}?context=edit&_fields=id,meta`,
587
1238
  { siteId: input.site_id }
588
1239
  );
589
1240
  const raw = page.meta?._elementor_data ?? "[]";
590
- const data = parseElementorData(raw);
591
- const dry = findReplaceInWidgets(JSON.parse(JSON.stringify(data)), input.find, input.replace, {
1241
+ const dryData = parseElementorData(raw);
1242
+ const dry = findReplaceInWidgets(JSON.parse(JSON.stringify(dryData)), input.find, input.replace, {
1243
+ widgetType: input.widget_type,
1244
+ caseSensitive: input.case_sensitive
1245
+ });
1246
+ const affected = [];
1247
+ const beforeData = parseElementorData(raw);
1248
+ const afterCopy = JSON.parse(JSON.stringify(beforeData));
1249
+ findReplaceInWidgets(afterCopy, input.find, input.replace, {
592
1250
  widgetType: input.widget_type,
593
1251
  caseSensitive: input.case_sensitive
594
1252
  });
1253
+ function collectAffected(orig, modified) {
1254
+ const beforeMap = /* @__PURE__ */ new Map();
1255
+ for (const { element } of walkElements(orig)) {
1256
+ if (element.elType !== "widget") continue;
1257
+ const firstStr = Object.values(element.settings ?? {}).find((v) => typeof v === "string");
1258
+ if (firstStr) beforeMap.set(element.id, { type: element.widgetType ?? "?", first: firstStr });
1259
+ }
1260
+ for (const { element } of walkElements(modified)) {
1261
+ if (element.elType !== "widget") continue;
1262
+ const firstStr = Object.values(element.settings ?? {}).find((v) => typeof v === "string");
1263
+ const b = beforeMap.get(element.id);
1264
+ if (b && firstStr && b.first !== firstStr) {
1265
+ affected.push({
1266
+ widget_id: element.id,
1267
+ widget_type: element.widgetType ?? "?",
1268
+ before: b.first.slice(0, 120),
1269
+ after: firstStr.slice(0, 120)
1270
+ });
1271
+ }
1272
+ }
1273
+ }
1274
+ collectAffected(beforeData, afterCopy);
595
1275
  if (!input.confirmation) {
596
1276
  if (dry.replacementCount === 0) {
597
- return { mode: "dry_run", page_id: input.page_id, match_count: 0 };
1277
+ return {
1278
+ mode: "dry_run",
1279
+ page_id: input.page_id,
1280
+ match_count: 0,
1281
+ affected_widgets: []
1282
+ };
598
1283
  }
599
1284
  const token = issueConfirmation(
600
1285
  "elementor_find_replace",
601
1286
  { page_id: input.page_id, find: input.find, replace: input.replace },
602
- cfg.confirmation_ttl_seconds
1287
+ POLICIES.CONFIRMATION_TTL_SECONDS
603
1288
  );
604
1289
  return {
605
1290
  mode: "dry_run",
606
1291
  page_id: input.page_id,
607
1292
  match_count: dry.replacementCount,
1293
+ affected_widgets: affected.slice(0, 25),
608
1294
  confirmation_token: token,
609
- expires_in_seconds: cfg.confirmation_ttl_seconds
1295
+ expires_in_seconds: POLICIES.CONFIRMATION_TTL_SECONDS
610
1296
  };
611
1297
  }
612
1298
  const conf = consumeConfirmation(input.confirmation, "elementor_find_replace");
613
1299
  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) {
1300
+ const o = conf.payload;
1301
+ if (o.page_id !== input.page_id || o.find !== input.find || o.replace !== input.replace) {
616
1302
  throw new Error("Confirmation parameters don't match the original dry-run");
617
1303
  }
618
- const backup = await backupElementorData(input.site_id, input.page_id);
619
- const applied = findReplaceInWidgets(parseElementorData(raw), input.find, input.replace, {
1304
+ const backup = await fullBackup(input.site_id, input.page_id, { to_file: input.backup_to_file });
1305
+ const newData = parseElementorData(raw);
1306
+ findReplaceInWidgets(newData, input.find, input.replace, {
620
1307
  widgetType: input.widget_type,
621
1308
  caseSensitive: input.case_sensitive
622
1309
  });
1310
+ const serialized = serializeElementorData(newData);
1311
+ const validation = validateElementorData(serialized);
1312
+ if (!validation.valid) {
1313
+ return {
1314
+ mode: "rolled_back",
1315
+ page_id: input.page_id,
1316
+ match_count: dry.replacementCount,
1317
+ affected_widgets: affected.slice(0, 25),
1318
+ backup_meta_key: backup.meta_key,
1319
+ validation_error: validation.errors.join("; ")
1320
+ };
1321
+ }
623
1322
  await wpRequest(`/wp/v2/pages/${input.page_id}`, {
624
1323
  siteId: input.site_id,
625
1324
  method: "PUT",
626
- body: { meta: { _elementor_data: serializeElementorData(applied.data) } }
1325
+ body: { meta: { _elementor_data: serialized } }
627
1326
  });
628
- const flush = await flushElementorCSS(input.site_id, input.page_id);
1327
+ const flush = POLICIES.FLUSH_CSS_AFTER_WRITE ? await flushCSS(input.site_id, input.page_id) : { method: "none" };
629
1328
  return {
630
1329
  mode: "applied",
631
1330
  page_id: input.page_id,
632
- match_count: applied.replacementCount,
1331
+ match_count: dry.replacementCount,
1332
+ affected_widgets: affected.slice(0, 25),
633
1333
  backup_meta_key: backup.meta_key,
1334
+ backup_file: backup.file_path,
1335
+ css_flush: flush.method
1336
+ };
1337
+ }
1338
+ });
1339
+ var listElementorBackupsTool = defineTool({
1340
+ name: "list_elementor_backups",
1341
+ 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.",
1342
+ inputSchema: z3.object({
1343
+ site_id: z3.string().optional(),
1344
+ page_id: z3.number().int().positive()
1345
+ }),
1346
+ outputSchema: z3.object({
1347
+ page_id: z3.number(),
1348
+ total: z3.number(),
1349
+ backups: z3.array(z3.object({
1350
+ meta_key: z3.string(),
1351
+ settings_key: z3.string().optional(),
1352
+ timestamp: z3.string()
1353
+ }))
1354
+ }),
1355
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
1356
+ async handler(input) {
1357
+ const backups = await listBackups(input.site_id, input.page_id);
1358
+ return {
1359
+ page_id: input.page_id,
1360
+ total: backups.length,
1361
+ backups
1362
+ };
1363
+ }
1364
+ });
1365
+ var restoreElementorBackupTool = defineTool({
1366
+ name: "restore_elementor_backup",
1367
+ 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.",
1368
+ inputSchema: z3.object({
1369
+ site_id: z3.string().optional(),
1370
+ page_id: z3.number().int().positive(),
1371
+ backup_meta_key: z3.string().describe("From list_elementor_backups."),
1372
+ settings_meta_key: z3.string().optional(),
1373
+ confirmation: z3.string().optional()
1374
+ }),
1375
+ outputSchema: z3.object({
1376
+ mode: z3.enum(["dry_run", "restored"]),
1377
+ page_id: z3.number(),
1378
+ backup_meta_key: z3.string(),
1379
+ settings_meta_key: z3.string().optional(),
1380
+ confirmation_token: z3.string().optional(),
1381
+ expires_in_seconds: z3.number().optional(),
1382
+ css_flush: z3.string().optional(),
1383
+ pre_restore_backup_meta_key: z3.string().optional()
1384
+ }),
1385
+ annotations: { destructiveHint: true, idempotentHint: false, openWorldHint: true },
1386
+ async handler(input) {
1387
+ if (!input.confirmation) {
1388
+ const token = issueConfirmation("restore_elementor_backup", {
1389
+ page_id: input.page_id,
1390
+ backup_meta_key: input.backup_meta_key
1391
+ }, POLICIES.CONFIRMATION_TTL_SECONDS);
1392
+ return {
1393
+ mode: "dry_run",
1394
+ page_id: input.page_id,
1395
+ backup_meta_key: input.backup_meta_key,
1396
+ settings_meta_key: input.settings_meta_key,
1397
+ confirmation_token: token,
1398
+ expires_in_seconds: POLICIES.CONFIRMATION_TTL_SECONDS
1399
+ };
1400
+ }
1401
+ const conf = consumeConfirmation(input.confirmation, "restore_elementor_backup");
1402
+ if (!conf) throw new Error("Invalid or expired confirmation token");
1403
+ const pre = await fullBackup(input.site_id, input.page_id);
1404
+ await restoreBackup(input.site_id, input.page_id, input.backup_meta_key, input.settings_meta_key);
1405
+ const flush = await flushCSS(input.site_id, input.page_id);
1406
+ return {
1407
+ mode: "restored",
1408
+ page_id: input.page_id,
1409
+ backup_meta_key: input.backup_meta_key,
1410
+ settings_meta_key: input.settings_meta_key,
1411
+ css_flush: flush.method,
1412
+ pre_restore_backup_meta_key: pre.meta_key
1413
+ };
1414
+ }
1415
+ });
1416
+ var duplicateElementorPageTool = defineTool({
1417
+ name: "duplicate_elementor_page",
1418
+ 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.",
1419
+ inputSchema: z3.object({
1420
+ site_id: z3.string().optional(),
1421
+ source_page_id: z3.number().int().positive(),
1422
+ new_title: z3.string().optional().describe("Defaults to '<original> (Copy)'"),
1423
+ status: z3.enum(["draft", "publish", "private"]).default("draft")
1424
+ }),
1425
+ outputSchema: z3.object({
1426
+ new_page_id: z3.number(),
1427
+ new_page_url: z3.string(),
1428
+ source_page_id: z3.number(),
1429
+ title: z3.string(),
1430
+ status: z3.string(),
1431
+ css_flush: z3.string().optional()
1432
+ }),
1433
+ annotations: { destructiveHint: false, idempotentHint: false, openWorldHint: true },
1434
+ async handler(input) {
1435
+ const source = await wpRequest(
1436
+ `/wp/v2/pages/${input.source_page_id}?context=edit`,
1437
+ { siteId: input.site_id }
1438
+ );
1439
+ const title = input.new_title ?? `${source.title.rendered} (Copy)`;
1440
+ const data_raw = source.meta?._elementor_data ?? "[]";
1441
+ const settings_raw = source.meta?._elementor_page_settings ?? "{}";
1442
+ const created = await wpRequest("/wp/v2/pages", {
1443
+ siteId: input.site_id,
1444
+ method: "POST",
1445
+ body: {
1446
+ title,
1447
+ status: input.status,
1448
+ meta: {
1449
+ _elementor_data: data_raw,
1450
+ _elementor_page_settings: parseSettingsForRest(settings_raw),
1451
+ _elementor_edit_mode: "builder"
1452
+ }
1453
+ }
1454
+ });
1455
+ const flush = await flushCSS(input.site_id, created.id);
1456
+ return {
1457
+ new_page_id: created.id,
1458
+ new_page_url: created.link,
1459
+ source_page_id: input.source_page_id,
1460
+ title,
1461
+ status: input.status,
634
1462
  css_flush: flush.method
635
1463
  };
636
1464
  }
637
1465
  });
1466
+ function parseSettingsForRest(raw) {
1467
+ if (typeof raw === "object" && raw !== null) return raw;
1468
+ if (typeof raw === "string") {
1469
+ if (!raw.trim()) return {};
1470
+ try {
1471
+ return JSON.parse(raw);
1472
+ } catch {
1473
+ return {};
1474
+ }
1475
+ }
1476
+ return {};
1477
+ }
638
1478
 
639
1479
  // src/tools/templates.ts
640
1480
  import { z as z4 } from "zod";
1481
+ init_wp_rest();
1482
+ var TEMPLATE_TYPES = ["section", "page", "popup", "header", "footer", "archive", "single", "widget", "any"];
641
1483
  var listTemplatesTool = defineTool({
642
1484
  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'.",
1485
+ 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
1486
  inputSchema: z4.object({
645
1487
  site_id: z4.string().optional(),
646
- type: z4.enum(["section", "page", "popup", "header", "footer", "any"]).default("any"),
1488
+ type: z4.enum(TEMPLATE_TYPES).default("any"),
647
1489
  per_page: z4.number().int().min(1).max(100).default(50)
648
1490
  }),
649
1491
  outputSchema: z4.object({
650
1492
  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
- )
1493
+ templates: z4.array(z4.object({
1494
+ id: z4.number(),
1495
+ title: z4.string(),
1496
+ type: z4.string(),
1497
+ is_theme_builder: z4.boolean(),
1498
+ display_conditions_count: z4.number(),
1499
+ modified: z4.string()
1500
+ }))
659
1501
  }),
660
1502
  annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
661
1503
  async handler(input) {
@@ -664,28 +1506,35 @@ var listTemplatesTool = defineTool({
664
1506
  context: "edit",
665
1507
  _fields: "id,title,modified,meta"
666
1508
  };
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
- });
1509
+ const allItems = await wpRequest("/wp/v2/elementor_library", { siteId: input.site_id, query });
1510
+ const items = input.type === "any" ? allItems : allItems.filter((t) => t.meta?._elementor_template_type === input.type);
1511
+ const themeBuilderTypes = /* @__PURE__ */ new Set(["header", "footer", "archive", "single"]);
675
1512
  return {
676
1513
  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
- }))
1514
+ templates: items.map((t) => {
1515
+ const type = t.meta?._elementor_template_type ?? "unknown";
1516
+ let conds = 0;
1517
+ try {
1518
+ const c = t.meta?._elementor_conditions;
1519
+ if (typeof c === "string") conds = JSON.parse(c).length;
1520
+ else if (Array.isArray(c)) conds = c.length;
1521
+ } catch {
1522
+ }
1523
+ return {
1524
+ id: t.id,
1525
+ title: t.title.rendered,
1526
+ type,
1527
+ is_theme_builder: themeBuilderTypes.has(type),
1528
+ display_conditions_count: conds,
1529
+ modified: t.modified
1530
+ };
1531
+ })
683
1532
  };
684
1533
  }
685
1534
  });
686
1535
  var exportTemplateTool = defineTool({
687
1536
  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.",
1537
+ 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
1538
  inputSchema: z4.object({
690
1539
  site_id: z4.string().optional(),
691
1540
  template_id: z4.number().int().positive()
@@ -694,18 +1543,12 @@ var exportTemplateTool = defineTool({
694
1543
  template_id: z4.number(),
695
1544
  title: z4.string(),
696
1545
  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.")
1546
+ summary: z4.object({ totalElements: z4.number(), widgets: z4.number(), sections: z4.number() }),
1547
+ portable_json: z4.string()
703
1548
  }),
704
1549
  annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
705
1550
  async handler(input) {
706
- const tpl = await wpRequest(`/wp/v2/elementor_library/${input.template_id}?context=edit`, {
707
- siteId: input.site_id
708
- });
1551
+ const tpl = await wpRequest(`/wp/v2/elementor_library/${input.template_id}?context=edit`, { siteId: input.site_id });
709
1552
  const data = parseElementorData(tpl.meta?._elementor_data ?? "[]");
710
1553
  const sum = summarize(data);
711
1554
  const portable = {
@@ -725,10 +1568,10 @@ var exportTemplateTool = defineTool({
725
1568
  });
726
1569
  var importTemplateTool = defineTool({
727
1570
  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.",
1571
+ 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
1572
  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."),
1573
+ site_id: z4.string().optional(),
1574
+ portable_json: z4.string(),
732
1575
  override_title: z4.string().optional()
733
1576
  }),
734
1577
  outputSchema: z4.object({
@@ -760,102 +1603,1272 @@ var importTemplateTool = defineTool({
760
1603
  }
761
1604
  }
762
1605
  });
1606
+ return { new_template_id: res.id, title, type: payload.type, url: res.link };
1607
+ }
1608
+ });
1609
+ var applyTemplateToPageTool = defineTool({
1610
+ name: "apply_template_to_page",
1611
+ 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.",
1612
+ inputSchema: z4.object({
1613
+ site_id: z4.string().optional(),
1614
+ source_id: z4.number().int().positive().describe("Source post id: a template or a page."),
1615
+ target_page_id: z4.number().int().positive(),
1616
+ backup_to_file: z4.boolean().default(false),
1617
+ confirmation: z4.string().optional()
1618
+ }),
1619
+ outputSchema: z4.object({
1620
+ mode: z4.enum(["dry_run", "applied"]),
1621
+ target_page_id: z4.number(),
1622
+ source_id: z4.number(),
1623
+ confirmation_token: z4.string().optional(),
1624
+ expires_in_seconds: z4.number().optional(),
1625
+ backup_meta_key: z4.string().optional(),
1626
+ backup_file: z4.string().optional(),
1627
+ css_flush: z4.string().optional()
1628
+ }),
1629
+ annotations: { destructiveHint: true, idempotentHint: false, openWorldHint: true },
1630
+ async handler(input) {
1631
+ const { issueConfirmation: issueConfirmation2, consumeConfirmation: consumeConfirmation2 } = await Promise.resolve().then(() => (init_confirmation(), confirmation_exports));
1632
+ const { POLICIES: POLICIES2 } = await Promise.resolve().then(() => (init_policies(), policies_exports));
1633
+ const { fullBackup: fullBackup2 } = await Promise.resolve().then(() => (init_backup(), backup_exports));
1634
+ const { flushCSS: flushCSS2 } = await Promise.resolve().then(() => (init_css_flush(), css_flush_exports));
1635
+ if (!input.confirmation) {
1636
+ const token = issueConfirmation2("apply_template_to_page", { src: input.source_id, tgt: input.target_page_id, site: input.site_id }, POLICIES2.CONFIRMATION_TTL_SECONDS);
1637
+ return {
1638
+ mode: "dry_run",
1639
+ target_page_id: input.target_page_id,
1640
+ source_id: input.source_id,
1641
+ confirmation_token: token,
1642
+ expires_in_seconds: POLICIES2.CONFIRMATION_TTL_SECONDS
1643
+ };
1644
+ }
1645
+ const conf = consumeConfirmation2(input.confirmation, "apply_template_to_page");
1646
+ if (!conf) throw new Error("Invalid or expired confirmation token");
1647
+ let src_meta;
1648
+ try {
1649
+ const src = await wpRequest(`/wp/v2/elementor_library/${input.source_id}?context=edit`, { siteId: input.site_id });
1650
+ src_meta = src.meta;
1651
+ } catch {
1652
+ const src = await wpRequest(`/wp/v2/pages/${input.source_id}?context=edit`, { siteId: input.site_id });
1653
+ src_meta = src.meta;
1654
+ }
1655
+ const data_raw = src_meta?._elementor_data ?? "[]";
1656
+ const settings_raw = src_meta?._elementor_page_settings ?? "{}";
1657
+ const backup = await fullBackup2(input.site_id, input.target_page_id, { to_file: input.backup_to_file });
1658
+ await wpRequest(`/wp/v2/pages/${input.target_page_id}`, {
1659
+ siteId: input.site_id,
1660
+ method: "PUT",
1661
+ body: {
1662
+ meta: {
1663
+ _elementor_data: data_raw,
1664
+ _elementor_page_settings: parseSettingsForRest2(settings_raw),
1665
+ _elementor_edit_mode: "builder"
1666
+ }
1667
+ }
1668
+ });
1669
+ const flush = await flushCSS2(input.site_id, input.target_page_id);
763
1670
  return {
764
- new_template_id: res.id,
765
- title,
766
- type: payload.type,
767
- url: res.link
1671
+ mode: "applied",
1672
+ target_page_id: input.target_page_id,
1673
+ source_id: input.source_id,
1674
+ backup_meta_key: backup.meta_key,
1675
+ backup_file: backup.file_path,
1676
+ css_flush: flush.method
768
1677
  };
769
1678
  }
770
1679
  });
1680
+ function parseSettingsForRest2(raw) {
1681
+ if (typeof raw === "object" && raw !== null) return raw;
1682
+ if (typeof raw === "string") {
1683
+ if (!raw.trim()) return {};
1684
+ try {
1685
+ return JSON.parse(raw);
1686
+ } catch {
1687
+ return {};
1688
+ }
1689
+ }
1690
+ return {};
1691
+ }
771
1692
 
772
- // src/tools/updates.ts
1693
+ // src/tools/wpcli.ts
773
1694
  import { z as z5 } from "zod";
774
- 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" };
777
- }
778
- var checkElementorVersionsTool = defineTool({
779
- 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.",
1695
+ init_config();
1696
+ init_ssh_wpcli();
1697
+ init_policies();
1698
+ init_confirmation();
1699
+ var wpCliRunTool = defineTool({
1700
+ name: "wp_cli_run",
1701
+ 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.",
781
1702
  inputSchema: z5.object({
782
- site_ids: z5.array(z5.string()).optional().describe("Subset of sites to check. Defaults to all.")
1703
+ site_id: z5.string().optional(),
1704
+ args: z5.string().min(1).describe("WP-CLI args without leading 'wp', e.g. 'post list --post_type=page'"),
1705
+ timeout_ms: z5.number().int().min(1e3).max(3e5).default(6e4),
1706
+ confirmation: z5.string().optional()
783
1707
  }),
784
1708
  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
- )
1709
+ mode: z5.enum(["dry_run_destructive", "executed"]),
1710
+ command: z5.string(),
1711
+ stdout: z5.string().optional(),
1712
+ stderr: z5.string().optional(),
1713
+ exit_code: z5.number().optional(),
1714
+ duration_ms: z5.number().optional(),
1715
+ destructive_pattern_detected: z5.boolean().optional(),
1716
+ confirmation_token: z5.string().optional(),
1717
+ expires_in_seconds: z5.number().optional()
797
1718
  }),
798
- annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
1719
+ annotations: { destructiveHint: true, idempotentHint: false, openWorldHint: true },
799
1720
  async handler(input) {
800
- const cfg = loadConfig();
801
- const latest = await fetchLatestElementor();
802
- const targets = input.site_ids ? cfg.sites.filter((s) => input.site_ids?.includes(s.id)) : cfg.sites;
803
- const rows = [];
804
- for (const site of targets) {
805
- try {
806
- const plugins = await wpRequest("/wp/v2/plugins", { siteId: site.id });
807
- let elementor_version;
808
- let elementor_pro_version;
809
- for (const p of plugins) {
810
- if (p.plugin.startsWith("elementor/") && p.plugin.endsWith("/elementor.php"))
811
- elementor_version = p.version;
812
- if (p.plugin.startsWith("elementor-pro/")) elementor_pro_version = p.version;
813
- }
814
- rows.push({
815
- site_id: site.id,
816
- url: site.url,
817
- elementor_version,
818
- elementor_pro_version,
819
- outdated_free: !!elementor_version && elementor_version !== latest.free && latest.free !== "unknown"
820
- });
821
- } catch (e) {
822
- rows.push({
823
- site_id: site.id,
824
- url: site.url,
825
- outdated_free: false,
826
- error: e.message
827
- });
1721
+ const forbidden = isForbiddenWpCli(input.args);
1722
+ if (forbidden.forbidden) {
1723
+ throw new Error(`Forbidden wp-cli command. ${forbidden.reason}`);
1724
+ }
1725
+ const isDestructive = isDestructiveWpCli(input.args);
1726
+ if (isDestructive && !input.confirmation) {
1727
+ const token = issueConfirmation("wp_cli_run", { site_id: input.site_id, args: input.args }, POLICIES.CONFIRMATION_TTL_SECONDS);
1728
+ return {
1729
+ mode: "dry_run_destructive",
1730
+ command: `wp <path> ${input.args}`,
1731
+ destructive_pattern_detected: true,
1732
+ confirmation_token: token,
1733
+ expires_in_seconds: POLICIES.CONFIRMATION_TTL_SECONDS
1734
+ };
1735
+ }
1736
+ if (isDestructive && input.confirmation) {
1737
+ const conf = consumeConfirmation(input.confirmation, "wp_cli_run");
1738
+ if (!conf) throw new Error("Invalid or expired confirmation token");
1739
+ const o = conf.payload;
1740
+ if (o.args !== input.args || o.site_id !== input.site_id) {
1741
+ throw new Error("Confirmation parameters don't match the original dry-run");
828
1742
  }
829
1743
  }
1744
+ const site = getSite(input.site_id);
1745
+ const r = await sshWpCli(site, input.args, { timeout_ms: input.timeout_ms });
830
1746
  return {
831
- checked: rows.length,
832
- latest_elementor_free: latest.free,
833
- sites: rows
1747
+ mode: "executed",
1748
+ command: r.command,
1749
+ stdout: r.stdout,
1750
+ stderr: r.stderr,
1751
+ exit_code: r.exitCode,
1752
+ duration_ms: r.duration_ms,
1753
+ destructive_pattern_detected: isDestructive
834
1754
  };
835
1755
  }
836
1756
  });
837
-
838
- // src/tools/index.ts
839
- var tools = [
840
- listSitesTool,
841
- pingSiteTool,
842
- listElementorPagesTool,
843
- readPageElementorTool,
844
- findReplaceTool,
845
- listTemplatesTool,
846
- exportTemplateTool,
847
- importTemplateTool,
848
- checkElementorVersionsTool
849
- ];
1757
+ var wpSearchReplaceTool = defineTool({
1758
+ name: "wp_search_replace",
1759
+ 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'.",
1760
+ inputSchema: z5.object({
1761
+ site_id: z5.string().optional(),
1762
+ find: z5.string().min(1),
1763
+ replace: z5.string(),
1764
+ table: z5.string().default("wp_postmeta"),
1765
+ include_columns: z5.string().optional().describe("e.g. 'meta_value'. Default: meta_value when table=wp_postmeta."),
1766
+ precise: z5.boolean().default(true),
1767
+ confirmation: z5.string().optional()
1768
+ }),
1769
+ outputSchema: z5.object({
1770
+ mode: z5.enum(["dry_run", "applied"]),
1771
+ command: z5.string(),
1772
+ stdout: z5.string().optional(),
1773
+ stderr: z5.string().optional(),
1774
+ exit_code: z5.number().optional(),
1775
+ replacement_count: z5.number().optional(),
1776
+ confirmation_token: z5.string().optional(),
1777
+ expires_in_seconds: z5.number().optional()
1778
+ }),
1779
+ annotations: { destructiveHint: true, idempotentHint: false, openWorldHint: true },
1780
+ async handler(input) {
1781
+ const includeCols = input.include_columns ?? (input.table === "wp_postmeta" ? "meta_value" : "");
1782
+ const baseArgs = [
1783
+ "search-replace",
1784
+ `'${input.find.replace(/'/g, "'\\''")}'`,
1785
+ `'${input.replace.replace(/'/g, "'\\''")}'`,
1786
+ input.table,
1787
+ includeCols ? `--include-columns=${includeCols}` : "",
1788
+ input.precise ? "--precise" : ""
1789
+ ].filter(Boolean).join(" ");
1790
+ const site = getSite(input.site_id);
1791
+ if (!input.confirmation) {
1792
+ const dry = await sshWpCli(site, baseArgs + " --dry-run");
1793
+ const m2 = dry.stdout.match(/Success: (\d+) replacement/);
1794
+ const count = m2 ? parseInt(m2[1], 10) : 0;
1795
+ if (count === 0) {
1796
+ return { mode: "dry_run", command: dry.command, stdout: dry.stdout, exit_code: dry.exitCode, replacement_count: 0 };
1797
+ }
1798
+ const token = issueConfirmation("wp_search_replace", { args: baseArgs, site_id: input.site_id }, POLICIES.CONFIRMATION_TTL_SECONDS);
1799
+ return {
1800
+ mode: "dry_run",
1801
+ command: dry.command,
1802
+ stdout: dry.stdout,
1803
+ replacement_count: count,
1804
+ confirmation_token: token,
1805
+ expires_in_seconds: POLICIES.CONFIRMATION_TTL_SECONDS
1806
+ };
1807
+ }
1808
+ const conf = consumeConfirmation(input.confirmation, "wp_search_replace");
1809
+ if (!conf) throw new Error("Invalid or expired confirmation token");
1810
+ const o = conf.payload;
1811
+ if (o.args !== baseArgs || o.site_id !== input.site_id) throw new Error("Confirmation params mismatch");
1812
+ const r = await sshWpCli(site, baseArgs);
1813
+ const m = r.stdout.match(/Success: (\d+) replacement/);
1814
+ return {
1815
+ mode: "applied",
1816
+ command: r.command,
1817
+ stdout: r.stdout,
1818
+ stderr: r.stderr,
1819
+ exit_code: r.exitCode,
1820
+ replacement_count: m ? parseInt(m[1], 10) : 0
1821
+ };
1822
+ }
1823
+ });
1824
+ var wpElementorFlushCssTool = defineTool({
1825
+ name: "wp_elementor_flush_css",
1826
+ 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.",
1827
+ inputSchema: z5.object({
1828
+ site_id: z5.string().optional(),
1829
+ page_id: z5.number().int().positive().optional().describe("Optional: flush only this page's cache. If omitted, flushes site-wide.")
1830
+ }),
1831
+ outputSchema: z5.object({
1832
+ method: z5.enum(["rest", "wp-cli", "option-delete", "resave", "none"]),
1833
+ details: z5.string().optional()
1834
+ }),
1835
+ annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: true },
1836
+ async handler(input) {
1837
+ const { flushCSS: flushCSS2 } = await Promise.resolve().then(() => (init_css_flush(), css_flush_exports));
1838
+ return flushCSS2(input.site_id, input.page_id);
1839
+ }
1840
+ });
1841
+ var wpPluginListTool = defineTool({
1842
+ name: "wp_plugin_list",
1843
+ 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.",
1844
+ inputSchema: z5.object({
1845
+ site_id: z5.string().optional(),
1846
+ only_outdated: z5.boolean().default(false)
1847
+ }),
1848
+ outputSchema: z5.object({
1849
+ total: z5.number(),
1850
+ plugins: z5.array(z5.object({
1851
+ name: z5.string(),
1852
+ status: z5.string(),
1853
+ version: z5.string(),
1854
+ update: z5.string().optional(),
1855
+ update_version: z5.string().optional()
1856
+ }))
1857
+ }),
1858
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
1859
+ async handler(input) {
1860
+ const site = getSite(input.site_id);
1861
+ const r = await sshWpCli(site, "plugin list --format=json");
1862
+ if (r.exitCode !== 0) throw new Error(`wp plugin list failed: ${r.stderr}`);
1863
+ const arr = JSON.parse(r.stdout);
1864
+ const filtered = input.only_outdated ? arr.filter((p) => p.update === "available") : arr;
1865
+ return { total: filtered.length, plugins: filtered };
1866
+ }
1867
+ });
1868
+ var wpPluginUpdateTool = defineTool({
1869
+ name: "wp_plugin_update",
1870
+ description: "Update one or more plugins on a site to their latest version. Requires confirmation token (uses wp-cli).",
1871
+ inputSchema: z5.object({
1872
+ site_id: z5.string().optional(),
1873
+ plugins: z5.array(z5.string()).min(1).describe("Plugin slugs to update, e.g. ['elementor', 'elementor-pro']. Use 'all' for everything outdated."),
1874
+ confirmation: z5.string().optional()
1875
+ }),
1876
+ outputSchema: z5.object({
1877
+ mode: z5.enum(["dry_run", "applied"]),
1878
+ plugins: z5.array(z5.string()),
1879
+ command: z5.string().optional(),
1880
+ stdout: z5.string().optional(),
1881
+ stderr: z5.string().optional(),
1882
+ exit_code: z5.number().optional(),
1883
+ confirmation_token: z5.string().optional(),
1884
+ expires_in_seconds: z5.number().optional()
1885
+ }),
1886
+ annotations: { destructiveHint: true, idempotentHint: false, openWorldHint: true },
1887
+ async handler(input) {
1888
+ if (!input.confirmation) {
1889
+ const token = issueConfirmation("wp_plugin_update", { site_id: input.site_id, plugins: input.plugins }, POLICIES.CONFIRMATION_TTL_SECONDS);
1890
+ return {
1891
+ mode: "dry_run",
1892
+ plugins: input.plugins,
1893
+ confirmation_token: token,
1894
+ expires_in_seconds: POLICIES.CONFIRMATION_TTL_SECONDS
1895
+ };
1896
+ }
1897
+ const conf = consumeConfirmation(input.confirmation, "wp_plugin_update");
1898
+ if (!conf) throw new Error("Invalid or expired confirmation token");
1899
+ const site = getSite(input.site_id);
1900
+ const target = input.plugins.includes("all") ? "--all" : input.plugins.map((p) => `'${p}'`).join(" ");
1901
+ const r = await sshWpCli(site, `plugin update ${target}`, { timeout_ms: 18e4 });
1902
+ return {
1903
+ mode: "applied",
1904
+ plugins: input.plugins,
1905
+ command: r.command,
1906
+ stdout: r.stdout,
1907
+ stderr: r.stderr,
1908
+ exit_code: r.exitCode
1909
+ };
1910
+ }
1911
+ });
1912
+
1913
+ // src/tools/visual.ts
1914
+ import { z as z6 } from "zod";
1915
+
1916
+ // src/transport/screenshot.ts
1917
+ init_logger();
1918
+ import { spawn as spawn2 } from "child_process";
1919
+ import { existsSync as existsSync2 } from "fs";
1920
+ import { tmpdir as tmpdir2 } from "os";
1921
+ import { join as join2 } from "path";
1922
+ import { randomBytes as randomBytes2 } from "crypto";
1923
+ var CHROME_PATHS = [
1924
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
1925
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
1926
+ "/usr/bin/google-chrome",
1927
+ "/usr/bin/chromium",
1928
+ "/usr/bin/chromium-browser"
1929
+ ];
1930
+ function findChrome() {
1931
+ if (process.env.ELEMENTOR_MCP_CHROME) {
1932
+ return existsSync2(process.env.ELEMENTOR_MCP_CHROME) ? process.env.ELEMENTOR_MCP_CHROME : null;
1933
+ }
1934
+ for (const p of CHROME_PATHS) {
1935
+ if (existsSync2(p)) return p;
1936
+ }
1937
+ return null;
1938
+ }
1939
+ async function screenshotUrl(url, opts = {}) {
1940
+ const chrome = findChrome();
1941
+ if (!chrome) {
1942
+ throw new Error(
1943
+ "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(", ")
1944
+ );
1945
+ }
1946
+ const filename = `elementor-mcp-${randomBytes2(4).toString("hex")}-${Date.now()}.png`;
1947
+ const path = join2(tmpdir2(), filename);
1948
+ const args = [
1949
+ "--headless=new",
1950
+ "--disable-gpu",
1951
+ "--no-sandbox",
1952
+ "--hide-scrollbars",
1953
+ `--window-size=${opts.width ?? 1440},${opts.height ?? 900}`,
1954
+ `--screenshot=${path}`,
1955
+ ...opts.full_page ? ["--virtual-time-budget=10000"] : [],
1956
+ "--default-background-color=00000000",
1957
+ url
1958
+ ];
1959
+ const timeout = opts.timeout_ms ?? 6e4;
1960
+ logger.debug({ url, chrome }, "screenshot");
1961
+ await new Promise((resolve3, reject) => {
1962
+ const child = spawn2(chrome, args, { stdio: ["ignore", "ignore", "pipe"] });
1963
+ let stderr = "";
1964
+ const killer = setTimeout(() => {
1965
+ child.kill("SIGKILL");
1966
+ reject(new Error(`Screenshot timed out after ${timeout}ms`));
1967
+ }, timeout);
1968
+ child.stderr.on("data", (b) => {
1969
+ stderr += b.toString();
1970
+ });
1971
+ child.on("close", (code) => {
1972
+ clearTimeout(killer);
1973
+ if (code !== 0) reject(new Error(`Chrome exited ${code}: ${stderr.slice(0, 300)}`));
1974
+ else resolve3();
1975
+ });
1976
+ child.on("error", (e) => {
1977
+ clearTimeout(killer);
1978
+ reject(e);
1979
+ });
1980
+ });
1981
+ if (!existsSync2(path)) throw new Error(`Screenshot file not created: ${path}`);
1982
+ const fs = await import("fs");
1983
+ const stat = fs.statSync(path);
1984
+ return { path, bytes: stat.size };
1985
+ }
1986
+
1987
+ // src/tools/visual.ts
1988
+ init_wp_rest();
1989
+ import { readFileSync as readFileSync3 } from "fs";
1990
+ import { createHash } from "crypto";
1991
+ var screenshotPageTool = defineTool({
1992
+ name: "screenshot_page",
1993
+ 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.",
1994
+ inputSchema: z6.object({
1995
+ site_id: z6.string().optional(),
1996
+ page_id: z6.number().int().positive().optional(),
1997
+ url: z6.string().url().optional().describe("Alternative to page_id: hit any URL directly."),
1998
+ width: z6.number().int().min(320).max(3840).default(1440),
1999
+ height: z6.number().int().min(240).max(2400).default(900),
2000
+ full_page: z6.boolean().default(false)
2001
+ }),
2002
+ outputSchema: z6.object({
2003
+ url: z6.string(),
2004
+ file_path: z6.string(),
2005
+ bytes: z6.number(),
2006
+ sha256: z6.string(),
2007
+ width: z6.number(),
2008
+ height: z6.number()
2009
+ }),
2010
+ annotations: { readOnlyHint: true, idempotentHint: false, openWorldHint: true },
2011
+ async handler(input) {
2012
+ let target = input.url;
2013
+ if (!target) {
2014
+ if (!input.page_id) throw new Error("Provide either 'url' or 'page_id'.");
2015
+ const page = await wpRequest(`/wp/v2/pages/${input.page_id}?_fields=link`, { siteId: input.site_id });
2016
+ target = page.link;
2017
+ }
2018
+ const shot = await screenshotUrl(target, { width: input.width, height: input.height, full_page: input.full_page });
2019
+ const hash = createHash("sha256").update(readFileSync3(shot.path)).digest("hex");
2020
+ return {
2021
+ url: target,
2022
+ file_path: shot.path,
2023
+ bytes: shot.bytes,
2024
+ sha256: hash,
2025
+ width: input.width,
2026
+ height: input.height
2027
+ };
2028
+ }
2029
+ });
2030
+ var compareScreenshotsTool = defineTool({
2031
+ name: "compare_screenshots",
2032
+ 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.",
2033
+ inputSchema: z6.object({
2034
+ before_path: z6.string(),
2035
+ after_path: z6.string()
2036
+ }),
2037
+ outputSchema: z6.object({
2038
+ identical: z6.boolean(),
2039
+ before_bytes: z6.number(),
2040
+ after_bytes: z6.number(),
2041
+ delta_bytes: z6.number(),
2042
+ before_sha256: z6.string(),
2043
+ after_sha256: z6.string()
2044
+ }),
2045
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false },
2046
+ async handler(input) {
2047
+ const a = readFileSync3(input.before_path);
2048
+ const b = readFileSync3(input.after_path);
2049
+ const ah = createHash("sha256").update(a).digest("hex");
2050
+ const bh = createHash("sha256").update(b).digest("hex");
2051
+ return {
2052
+ identical: ah === bh,
2053
+ before_bytes: a.length,
2054
+ after_bytes: b.length,
2055
+ delta_bytes: b.length - a.length,
2056
+ before_sha256: ah,
2057
+ after_sha256: bh
2058
+ };
2059
+ }
2060
+ });
2061
+
2062
+ // src/tools/updates.ts
2063
+ import { z as z7 } from "zod";
2064
+ init_wp_rest();
2065
+ init_config();
2066
+ async function fetchLatestElementor() {
2067
+ try {
2068
+ const free = await fetch("https://api.wordpress.org/plugins/info/1.0/elementor.json", {
2069
+ headers: { "User-Agent": "elementor-mcp-agent" }
2070
+ }).then((r) => r.json());
2071
+ return { free: free.version ?? "unknown" };
2072
+ } catch {
2073
+ return { free: "unknown" };
2074
+ }
2075
+ }
2076
+ var checkElementorVersionsTool = defineTool({
2077
+ name: "check_elementor_versions",
2078
+ description: "Fleet-wide Elementor version audit. For every site, fetches installed Elementor/Pro versions and compares against wordpress.org latest. Flags outdated installs.",
2079
+ inputSchema: z7.object({
2080
+ site_ids: z7.array(z7.string()).optional()
2081
+ }),
2082
+ outputSchema: z7.object({
2083
+ checked: z7.number(),
2084
+ latest_elementor_free: z7.string(),
2085
+ sites: z7.array(z7.object({
2086
+ site_id: z7.string(),
2087
+ url: z7.string(),
2088
+ elementor_version: z7.string().optional(),
2089
+ elementor_pro_version: z7.string().optional(),
2090
+ outdated_free: z7.boolean(),
2091
+ error: z7.string().optional()
2092
+ }))
2093
+ }),
2094
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
2095
+ async handler(input) {
2096
+ const cfg = loadConfig();
2097
+ const latest = await fetchLatestElementor();
2098
+ const targets = input.site_ids ? cfg.sites.filter((s) => input.site_ids?.includes(s.id)) : cfg.sites;
2099
+ const rows = [];
2100
+ for (const site of targets) {
2101
+ try {
2102
+ const plugins = await wpRequest("/wp/v2/plugins", { siteId: site.id });
2103
+ let elementor_version;
2104
+ let elementor_pro_version;
2105
+ for (const p of plugins) {
2106
+ if (p.plugin.startsWith("elementor/") && p.plugin.endsWith("/elementor.php")) elementor_version = p.version;
2107
+ if (p.plugin.startsWith("elementor-pro/")) elementor_pro_version = p.version;
2108
+ }
2109
+ rows.push({
2110
+ site_id: site.id,
2111
+ url: site.url,
2112
+ elementor_version,
2113
+ elementor_pro_version,
2114
+ outdated_free: !!elementor_version && elementor_version !== latest.free && latest.free !== "unknown"
2115
+ });
2116
+ } catch (e) {
2117
+ rows.push({ site_id: site.id, url: site.url, outdated_free: false, error: e.message });
2118
+ }
2119
+ }
2120
+ return { checked: rows.length, latest_elementor_free: latest.free, sites: rows };
2121
+ }
2122
+ });
2123
+
2124
+ // src/tools/widgets.ts
2125
+ import { z as z8 } from "zod";
2126
+ init_wp_rest();
2127
+
2128
+ // src/elementor/widget-ops.ts
2129
+ function readWidget(data, widgetId) {
2130
+ return findElementById(data, widgetId);
2131
+ }
2132
+ function updateWidgetSettings(data, widgetId, patch) {
2133
+ const widget = findElementById(data, widgetId);
2134
+ if (!widget || widget.elType !== "widget") return false;
2135
+ widget.settings = { ...widget.settings, ...patch };
2136
+ return true;
2137
+ }
2138
+ function deleteWidget(data, widgetId) {
2139
+ function removeFrom(arr) {
2140
+ for (let i = 0; i < arr.length; i++) {
2141
+ if (arr[i].id === widgetId) {
2142
+ arr.splice(i, 1);
2143
+ return true;
2144
+ }
2145
+ if (arr[i].elements && removeFrom(arr[i].elements)) return true;
2146
+ }
2147
+ return false;
2148
+ }
2149
+ return removeFrom(data);
2150
+ }
2151
+ function findParent(data, widgetId) {
2152
+ for (const { element } of walkElements(data)) {
2153
+ if (element.elements?.some((e) => e.id === widgetId)) return element;
2154
+ }
2155
+ return null;
2156
+ }
2157
+ function duplicateWidget(data, widgetId) {
2158
+ const parent = findParent(data, widgetId);
2159
+ if (!parent || !parent.elements) return { ok: false };
2160
+ const idx = parent.elements.findIndex((e) => e.id === widgetId);
2161
+ if (idx < 0) return { ok: false };
2162
+ const clone = JSON.parse(JSON.stringify(parent.elements[idx]));
2163
+ clone.id = generateId();
2164
+ parent.elements.splice(idx + 1, 0, clone);
2165
+ return { ok: true, new_widget_id: clone.id };
2166
+ }
2167
+ function swapWidgetType(data, widgetId, newType, newSettings = {}) {
2168
+ const widget = findElementById(data, widgetId);
2169
+ if (!widget || widget.elType !== "widget") return false;
2170
+ widget.widgetType = newType;
2171
+ widget.settings = newSettings;
2172
+ return true;
2173
+ }
2174
+ function addWidget(data, parentId, widgetType, settings = {}) {
2175
+ const parent = findElementById(data, parentId);
2176
+ if (!parent) return { ok: false };
2177
+ if (!parent.elements) parent.elements = [];
2178
+ const newWidget = {
2179
+ id: generateId(),
2180
+ elType: "widget",
2181
+ widgetType,
2182
+ settings,
2183
+ elements: [],
2184
+ isInner: false
2185
+ };
2186
+ parent.elements.push(newWidget);
2187
+ return { ok: true, new_widget_id: newWidget.id };
2188
+ }
2189
+ function moveWidget(data, widgetId, newParentId, position = -1) {
2190
+ const widget = findElementById(data, widgetId);
2191
+ if (!widget) return false;
2192
+ const oldParent = findParent(data, widgetId);
2193
+ if (!oldParent || !oldParent.elements) return false;
2194
+ const newParent = findElementById(data, newParentId);
2195
+ if (!newParent) return false;
2196
+ if (!newParent.elements) newParent.elements = [];
2197
+ const idx = oldParent.elements.findIndex((e) => e.id === widgetId);
2198
+ if (idx < 0) return false;
2199
+ oldParent.elements.splice(idx, 1);
2200
+ if (position < 0 || position >= newParent.elements.length) {
2201
+ newParent.elements.push(widget);
2202
+ } else {
2203
+ newParent.elements.splice(position, 0, widget);
2204
+ }
2205
+ return true;
2206
+ }
2207
+ function generateId() {
2208
+ return Math.random().toString(16).slice(2, 9).padEnd(7, "0");
2209
+ }
2210
+
2211
+ // src/tools/widgets.ts
2212
+ init_backup();
2213
+ init_css_flush();
2214
+ init_policies();
2215
+ init_confirmation();
2216
+ async function fetchData(siteId, pageId) {
2217
+ const page = await wpRequest(
2218
+ `/wp/v2/pages/${pageId}?context=edit&_fields=meta`,
2219
+ { siteId }
2220
+ );
2221
+ const v = page.meta?._elementor_data;
2222
+ const raw = typeof v === "string" ? v : JSON.stringify(v ?? []);
2223
+ return { raw, data: parseElementorData(raw) };
2224
+ }
2225
+ async function writeData(siteId, pageId, data) {
2226
+ const ser = serializeElementorData(data);
2227
+ const validation = validateElementorData(ser);
2228
+ if (!validation.valid) {
2229
+ throw new Error("Validation failed after edit: " + validation.errors.join("; "));
2230
+ }
2231
+ await wpRequest(`/wp/v2/pages/${pageId}`, {
2232
+ siteId,
2233
+ method: "PUT",
2234
+ body: { meta: { _elementor_data: ser } }
2235
+ });
2236
+ const flush = await flushCSS(siteId, pageId);
2237
+ return { method: flush.method };
2238
+ }
2239
+ var readWidgetTool = defineTool({
2240
+ name: "read_widget",
2241
+ description: "Fetch a single widget's full settings by id. Use list_widgets_in_page to find the id first.",
2242
+ inputSchema: z8.object({
2243
+ site_id: z8.string().optional(),
2244
+ page_id: z8.number().int().positive(),
2245
+ widget_id: z8.string().min(1)
2246
+ }),
2247
+ outputSchema: z8.object({
2248
+ widget_id: z8.string(),
2249
+ widget_type: z8.string().optional(),
2250
+ settings: z8.record(z8.any())
2251
+ }),
2252
+ annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
2253
+ async handler(input) {
2254
+ const { data } = await fetchData(input.site_id, input.page_id);
2255
+ const w = readWidget(data, input.widget_id);
2256
+ if (!w) throw new Error(`Widget ${input.widget_id} not found on page ${input.page_id}`);
2257
+ return { widget_id: w.id, widget_type: w.widgetType, settings: w.settings };
2258
+ }
2259
+ });
2260
+ var updateWidgetSettingsTool = defineTool({
2261
+ name: "update_widget_settings",
2262
+ description: "Shallow-merge a partial settings object into one widget. Backs up the page first; validates the result; auto-flushes CSS. Two-call confirmation flow.",
2263
+ inputSchema: z8.object({
2264
+ site_id: z8.string().optional(),
2265
+ page_id: z8.number().int().positive(),
2266
+ widget_id: z8.string().min(1),
2267
+ settings_patch: z8.record(z8.any()),
2268
+ confirmation: z8.string().optional()
2269
+ }),
2270
+ outputSchema: z8.object({
2271
+ mode: z8.enum(["dry_run", "applied"]),
2272
+ page_id: z8.number(),
2273
+ widget_id: z8.string(),
2274
+ keys_changed: z8.array(z8.string()),
2275
+ confirmation_token: z8.string().optional(),
2276
+ backup_meta_key: z8.string().optional(),
2277
+ css_flush: z8.string().optional()
2278
+ }),
2279
+ annotations: { destructiveHint: true, idempotentHint: false, openWorldHint: true },
2280
+ async handler(input) {
2281
+ if (!input.confirmation) {
2282
+ const token = issueConfirmation("update_widget_settings", input, POLICIES.CONFIRMATION_TTL_SECONDS);
2283
+ return {
2284
+ mode: "dry_run",
2285
+ page_id: input.page_id,
2286
+ widget_id: input.widget_id,
2287
+ keys_changed: Object.keys(input.settings_patch),
2288
+ confirmation_token: token
2289
+ };
2290
+ }
2291
+ const conf = consumeConfirmation(input.confirmation, "update_widget_settings");
2292
+ if (!conf) throw new Error("Invalid or expired confirmation token");
2293
+ const { data } = await fetchData(input.site_id, input.page_id);
2294
+ if (!updateWidgetSettings(data, input.widget_id, input.settings_patch)) {
2295
+ throw new Error(`Widget ${input.widget_id} not found`);
2296
+ }
2297
+ const backup = await fullBackup(input.site_id, input.page_id);
2298
+ const w = await writeData(input.site_id, input.page_id, data);
2299
+ return {
2300
+ mode: "applied",
2301
+ page_id: input.page_id,
2302
+ widget_id: input.widget_id,
2303
+ keys_changed: Object.keys(input.settings_patch),
2304
+ backup_meta_key: backup.meta_key,
2305
+ css_flush: w.method
2306
+ };
2307
+ }
2308
+ });
2309
+ var deleteWidgetTool = defineTool({
2310
+ name: "delete_widget",
2311
+ description: "Remove a widget from a page by id. Two-call confirmation. Backs up before deleting.",
2312
+ inputSchema: z8.object({
2313
+ site_id: z8.string().optional(),
2314
+ page_id: z8.number().int().positive(),
2315
+ widget_id: z8.string().min(1),
2316
+ confirmation: z8.string().optional()
2317
+ }),
2318
+ outputSchema: z8.object({
2319
+ mode: z8.enum(["dry_run", "applied"]),
2320
+ page_id: z8.number(),
2321
+ widget_id: z8.string(),
2322
+ confirmation_token: z8.string().optional(),
2323
+ backup_meta_key: z8.string().optional(),
2324
+ css_flush: z8.string().optional()
2325
+ }),
2326
+ annotations: { destructiveHint: true, idempotentHint: false, openWorldHint: true },
2327
+ async handler(input) {
2328
+ if (!input.confirmation) {
2329
+ const token = issueConfirmation("delete_widget", input, POLICIES.CONFIRMATION_TTL_SECONDS);
2330
+ return { mode: "dry_run", page_id: input.page_id, widget_id: input.widget_id, confirmation_token: token };
2331
+ }
2332
+ const conf = consumeConfirmation(input.confirmation, "delete_widget");
2333
+ if (!conf) throw new Error("Invalid or expired confirmation token");
2334
+ const { data } = await fetchData(input.site_id, input.page_id);
2335
+ if (!deleteWidget(data, input.widget_id)) throw new Error(`Widget ${input.widget_id} not found`);
2336
+ const backup = await fullBackup(input.site_id, input.page_id);
2337
+ const w = await writeData(input.site_id, input.page_id, data);
2338
+ return {
2339
+ mode: "applied",
2340
+ page_id: input.page_id,
2341
+ widget_id: input.widget_id,
2342
+ backup_meta_key: backup.meta_key,
2343
+ css_flush: w.method
2344
+ };
2345
+ }
2346
+ });
2347
+ var duplicateWidgetTool = defineTool({
2348
+ name: "duplicate_widget",
2349
+ description: "Duplicate a widget in place (right after the original). The clone gets a new id. Two-call confirmation.",
2350
+ inputSchema: z8.object({
2351
+ site_id: z8.string().optional(),
2352
+ page_id: z8.number().int().positive(),
2353
+ widget_id: z8.string().min(1),
2354
+ confirmation: z8.string().optional()
2355
+ }),
2356
+ outputSchema: z8.object({
2357
+ mode: z8.enum(["dry_run", "applied"]),
2358
+ page_id: z8.number(),
2359
+ source_widget_id: z8.string(),
2360
+ new_widget_id: z8.string().optional(),
2361
+ confirmation_token: z8.string().optional(),
2362
+ backup_meta_key: z8.string().optional(),
2363
+ css_flush: z8.string().optional()
2364
+ }),
2365
+ annotations: { destructiveHint: false, idempotentHint: false, openWorldHint: true },
2366
+ async handler(input) {
2367
+ if (!input.confirmation) {
2368
+ const token = issueConfirmation("duplicate_widget", input, POLICIES.CONFIRMATION_TTL_SECONDS);
2369
+ return { mode: "dry_run", page_id: input.page_id, source_widget_id: input.widget_id, confirmation_token: token };
2370
+ }
2371
+ const conf = consumeConfirmation(input.confirmation, "duplicate_widget");
2372
+ if (!conf) throw new Error("Invalid or expired confirmation token");
2373
+ const { data } = await fetchData(input.site_id, input.page_id);
2374
+ const r = duplicateWidget(data, input.widget_id);
2375
+ if (!r.ok) throw new Error(`Widget ${input.widget_id} not found`);
2376
+ const backup = await fullBackup(input.site_id, input.page_id);
2377
+ const w = await writeData(input.site_id, input.page_id, data);
2378
+ return {
2379
+ mode: "applied",
2380
+ page_id: input.page_id,
2381
+ source_widget_id: input.widget_id,
2382
+ new_widget_id: r.new_widget_id,
2383
+ backup_meta_key: backup.meta_key,
2384
+ css_flush: w.method
2385
+ };
2386
+ }
2387
+ });
2388
+ var swapWidgetTypeTool = defineTool({
2389
+ name: "swap_widget_type",
2390
+ description: "Replace a widget's type (e.g., heading \u2192 button) while preserving its position. Provide full new_settings \u2014 the old settings are NOT carried over (different widget types have incompatible schemas). Two-call confirmation.",
2391
+ inputSchema: z8.object({
2392
+ site_id: z8.string().optional(),
2393
+ page_id: z8.number().int().positive(),
2394
+ widget_id: z8.string().min(1),
2395
+ new_widget_type: z8.string().min(1),
2396
+ new_settings: z8.record(z8.any()).default({}),
2397
+ confirmation: z8.string().optional()
2398
+ }),
2399
+ outputSchema: z8.object({
2400
+ mode: z8.enum(["dry_run", "applied"]),
2401
+ page_id: z8.number(),
2402
+ widget_id: z8.string(),
2403
+ new_widget_type: z8.string(),
2404
+ confirmation_token: z8.string().optional(),
2405
+ backup_meta_key: z8.string().optional(),
2406
+ css_flush: z8.string().optional()
2407
+ }),
2408
+ annotations: { destructiveHint: true, idempotentHint: false, openWorldHint: true },
2409
+ async handler(input) {
2410
+ if (!input.confirmation) {
2411
+ const token = issueConfirmation("swap_widget_type", input, POLICIES.CONFIRMATION_TTL_SECONDS);
2412
+ return { mode: "dry_run", page_id: input.page_id, widget_id: input.widget_id, new_widget_type: input.new_widget_type, confirmation_token: token };
2413
+ }
2414
+ const conf = consumeConfirmation(input.confirmation, "swap_widget_type");
2415
+ if (!conf) throw new Error("Invalid or expired confirmation token");
2416
+ const { data } = await fetchData(input.site_id, input.page_id);
2417
+ if (!swapWidgetType(data, input.widget_id, input.new_widget_type, input.new_settings)) {
2418
+ throw new Error(`Widget ${input.widget_id} not found`);
2419
+ }
2420
+ const backup = await fullBackup(input.site_id, input.page_id);
2421
+ const w = await writeData(input.site_id, input.page_id, data);
2422
+ return {
2423
+ mode: "applied",
2424
+ page_id: input.page_id,
2425
+ widget_id: input.widget_id,
2426
+ new_widget_type: input.new_widget_type,
2427
+ backup_meta_key: backup.meta_key,
2428
+ css_flush: w.method
2429
+ };
2430
+ }
2431
+ });
2432
+ var addWidgetTool = defineTool({
2433
+ name: "add_widget",
2434
+ description: "Append a new widget to a parent container (section, column, or container) on a page. Two-call confirmation.",
2435
+ inputSchema: z8.object({
2436
+ site_id: z8.string().optional(),
2437
+ page_id: z8.number().int().positive(),
2438
+ parent_id: z8.string().min(1).describe("Id of the section/column/container that will receive the widget."),
2439
+ widget_type: z8.string().min(1).describe("e.g., 'heading', 'text-editor', 'button', 'image'."),
2440
+ settings: z8.record(z8.any()).default({}),
2441
+ confirmation: z8.string().optional()
2442
+ }),
2443
+ outputSchema: z8.object({
2444
+ mode: z8.enum(["dry_run", "applied"]),
2445
+ page_id: z8.number(),
2446
+ parent_id: z8.string(),
2447
+ widget_type: z8.string(),
2448
+ new_widget_id: z8.string().optional(),
2449
+ confirmation_token: z8.string().optional(),
2450
+ backup_meta_key: z8.string().optional(),
2451
+ css_flush: z8.string().optional()
2452
+ }),
2453
+ annotations: { destructiveHint: false, idempotentHint: false, openWorldHint: true },
2454
+ async handler(input) {
2455
+ if (!input.confirmation) {
2456
+ const token = issueConfirmation("add_widget", input, POLICIES.CONFIRMATION_TTL_SECONDS);
2457
+ return { mode: "dry_run", page_id: input.page_id, parent_id: input.parent_id, widget_type: input.widget_type, confirmation_token: token };
2458
+ }
2459
+ const conf = consumeConfirmation(input.confirmation, "add_widget");
2460
+ if (!conf) throw new Error("Invalid or expired confirmation token");
2461
+ const { data } = await fetchData(input.site_id, input.page_id);
2462
+ const r = addWidget(data, input.parent_id, input.widget_type, input.settings);
2463
+ if (!r.ok) throw new Error(`Parent ${input.parent_id} not found`);
2464
+ const backup = await fullBackup(input.site_id, input.page_id);
2465
+ const w = await writeData(input.site_id, input.page_id, data);
2466
+ return {
2467
+ mode: "applied",
2468
+ page_id: input.page_id,
2469
+ parent_id: input.parent_id,
2470
+ widget_type: input.widget_type,
2471
+ new_widget_id: r.new_widget_id,
2472
+ backup_meta_key: backup.meta_key,
2473
+ css_flush: w.method
2474
+ };
2475
+ }
2476
+ });
2477
+ var moveWidgetTool = defineTool({
2478
+ name: "move_widget",
2479
+ description: "Move a widget to a different parent (or different position in the same parent). Two-call confirmation.",
2480
+ inputSchema: z8.object({
2481
+ site_id: z8.string().optional(),
2482
+ page_id: z8.number().int().positive(),
2483
+ widget_id: z8.string().min(1),
2484
+ new_parent_id: z8.string().min(1),
2485
+ position: z8.number().int().default(-1).describe("0-based position in the new parent. -1 = append."),
2486
+ confirmation: z8.string().optional()
2487
+ }),
2488
+ outputSchema: z8.object({
2489
+ mode: z8.enum(["dry_run", "applied"]),
2490
+ page_id: z8.number(),
2491
+ widget_id: z8.string(),
2492
+ new_parent_id: z8.string(),
2493
+ confirmation_token: z8.string().optional(),
2494
+ backup_meta_key: z8.string().optional(),
2495
+ css_flush: z8.string().optional()
2496
+ }),
2497
+ annotations: { destructiveHint: true, idempotentHint: false, openWorldHint: true },
2498
+ async handler(input) {
2499
+ if (!input.confirmation) {
2500
+ const token = issueConfirmation("move_widget", input, POLICIES.CONFIRMATION_TTL_SECONDS);
2501
+ return { mode: "dry_run", page_id: input.page_id, widget_id: input.widget_id, new_parent_id: input.new_parent_id, confirmation_token: token };
2502
+ }
2503
+ const conf = consumeConfirmation(input.confirmation, "move_widget");
2504
+ if (!conf) throw new Error("Invalid or expired confirmation token");
2505
+ const { data } = await fetchData(input.site_id, input.page_id);
2506
+ if (!moveWidget(data, input.widget_id, input.new_parent_id, input.position)) {
2507
+ throw new Error(`Widget or parent not found`);
2508
+ }
2509
+ const backup = await fullBackup(input.site_id, input.page_id);
2510
+ const w = await writeData(input.site_id, input.page_id, data);
2511
+ return {
2512
+ mode: "applied",
2513
+ page_id: input.page_id,
2514
+ widget_id: input.widget_id,
2515
+ new_parent_id: input.new_parent_id,
2516
+ backup_meta_key: backup.meta_key,
2517
+ css_flush: w.method
2518
+ };
2519
+ }
2520
+ });
2521
+
2522
+ // src/tools/bulk.ts
2523
+ import { z as z9 } from "zod";
2524
+ init_wp_rest();
2525
+ init_config();
2526
+ init_backup();
2527
+ init_css_flush();
2528
+ init_policies();
2529
+ init_confirmation();
2530
+ async function listElementorPageIds(siteId) {
2531
+ const out = [];
2532
+ let page = 1;
2533
+ for (; ; ) {
2534
+ const items = await wpRequest("/wp/v2/pages", {
2535
+ siteId,
2536
+ query: {
2537
+ meta_key: "_elementor_edit_mode",
2538
+ meta_value: "builder",
2539
+ context: "edit",
2540
+ per_page: 100,
2541
+ page,
2542
+ _fields: "id,title"
2543
+ }
2544
+ });
2545
+ if (items.length === 0) break;
2546
+ out.push(...items.map((p) => ({ id: p.id, title: p.title.rendered })));
2547
+ if (items.length < 100) break;
2548
+ page++;
2549
+ if (page > 50) break;
2550
+ }
2551
+ return out;
2552
+ }
2553
+ var bulkFindReplaceSiteTool = defineTool({
2554
+ name: "bulk_find_replace_site",
2555
+ description: "Find/replace plain text in every Elementor page on a single site. TWO-CALL FLOW: dry-run returns per-page match_count + total + confirmation_token. Apply iterates each page (auto-backup + validate + flush per page). Slower than wp_search_replace but works without SSH and gives per-page granularity.",
2556
+ inputSchema: z9.object({
2557
+ site_id: z9.string().optional(),
2558
+ find: z9.string().min(1),
2559
+ replace: z9.string(),
2560
+ widget_type: z9.string().optional(),
2561
+ case_sensitive: z9.boolean().default(false),
2562
+ confirmation: z9.string().optional()
2563
+ }),
2564
+ outputSchema: z9.object({
2565
+ mode: z9.enum(["dry_run", "applied"]),
2566
+ site_id: z9.string(),
2567
+ pages_scanned: z9.number(),
2568
+ total_match_count: z9.number(),
2569
+ pages_with_matches: z9.array(z9.object({
2570
+ page_id: z9.number(),
2571
+ title: z9.string(),
2572
+ match_count: z9.number()
2573
+ })),
2574
+ pages_applied: z9.array(z9.object({
2575
+ page_id: z9.number(),
2576
+ backup_meta_key: z9.string().optional(),
2577
+ css_flush: z9.string().optional(),
2578
+ mode: z9.enum(["applied", "rolled_back", "skipped"]),
2579
+ error: z9.string().optional()
2580
+ })).optional(),
2581
+ confirmation_token: z9.string().optional(),
2582
+ expires_in_seconds: z9.number().optional()
2583
+ }),
2584
+ annotations: { destructiveHint: true, idempotentHint: false, openWorldHint: true },
2585
+ async handler(input) {
2586
+ const pages = await listElementorPageIds(input.site_id);
2587
+ const matches = [];
2588
+ for (const p of pages) {
2589
+ try {
2590
+ const page = await wpRequest(`/wp/v2/pages/${p.id}?context=edit&_fields=meta`, { siteId: input.site_id });
2591
+ const v = page.meta?._elementor_data;
2592
+ const raw = typeof v === "string" ? v : JSON.stringify(v ?? []);
2593
+ const data = parseElementorData(raw);
2594
+ const dry = findReplaceInWidgets(JSON.parse(JSON.stringify(data)), input.find, input.replace, {
2595
+ widgetType: input.widget_type,
2596
+ caseSensitive: input.case_sensitive
2597
+ });
2598
+ if (dry.replacementCount > 0) matches.push({ page_id: p.id, title: p.title, match_count: dry.replacementCount });
2599
+ } catch {
2600
+ }
2601
+ }
2602
+ const total = matches.reduce((s, m) => s + m.match_count, 0);
2603
+ if (!input.confirmation) {
2604
+ if (total === 0) {
2605
+ return {
2606
+ mode: "dry_run",
2607
+ site_id: loadConfig().default_site_id ?? "default",
2608
+ pages_scanned: pages.length,
2609
+ total_match_count: 0,
2610
+ pages_with_matches: []
2611
+ };
2612
+ }
2613
+ const token = issueConfirmation("bulk_find_replace_site", { find: input.find, replace: input.replace, page_ids: matches.map((m) => m.page_id) }, POLICIES.CONFIRMATION_TTL_SECONDS);
2614
+ return {
2615
+ mode: "dry_run",
2616
+ site_id: input.site_id ?? loadConfig().default_site_id ?? "default",
2617
+ pages_scanned: pages.length,
2618
+ total_match_count: total,
2619
+ pages_with_matches: matches,
2620
+ confirmation_token: token,
2621
+ expires_in_seconds: POLICIES.CONFIRMATION_TTL_SECONDS
2622
+ };
2623
+ }
2624
+ const conf = consumeConfirmation(input.confirmation, "bulk_find_replace_site");
2625
+ if (!conf) throw new Error("Invalid or expired confirmation token");
2626
+ const applied = [];
2627
+ for (const m of matches) {
2628
+ try {
2629
+ const page = await wpRequest(`/wp/v2/pages/${m.page_id}?context=edit&_fields=meta`, { siteId: input.site_id });
2630
+ const v = page.meta?._elementor_data;
2631
+ const raw = typeof v === "string" ? v : JSON.stringify(v ?? []);
2632
+ const data = parseElementorData(raw);
2633
+ findReplaceInWidgets(data, input.find, input.replace, { widgetType: input.widget_type, caseSensitive: input.case_sensitive });
2634
+ const ser = serializeElementorData(data);
2635
+ const validation = validateElementorData(ser);
2636
+ if (!validation.valid) {
2637
+ applied.push({ page_id: m.page_id, mode: "rolled_back", error: validation.errors.join("; ") });
2638
+ continue;
2639
+ }
2640
+ const backup = await fullBackup(input.site_id, m.page_id);
2641
+ await wpRequest(`/wp/v2/pages/${m.page_id}`, {
2642
+ siteId: input.site_id,
2643
+ method: "PUT",
2644
+ body: { meta: { _elementor_data: ser } }
2645
+ });
2646
+ const flush = await flushCSS(input.site_id, m.page_id);
2647
+ applied.push({ page_id: m.page_id, backup_meta_key: backup.meta_key, css_flush: flush.method, mode: "applied" });
2648
+ } catch (e) {
2649
+ applied.push({ page_id: m.page_id, mode: "skipped", error: e.message });
2650
+ }
2651
+ }
2652
+ return {
2653
+ mode: "applied",
2654
+ site_id: input.site_id ?? loadConfig().default_site_id ?? "default",
2655
+ pages_scanned: pages.length,
2656
+ total_match_count: total,
2657
+ pages_with_matches: matches,
2658
+ pages_applied: applied
2659
+ };
2660
+ }
2661
+ });
2662
+ var fleetFindReplaceTool = defineTool({
2663
+ name: "fleet_find_replace",
2664
+ description: "Find/replace plain text across every Elementor page of every site in the pool. Same flow as bulk_find_replace_site but iterates across sites. Returns per-site + grand-total summary. Dry-run first; second call applies. Use sparingly \u2014 this is the nuclear option.",
2665
+ inputSchema: z9.object({
2666
+ find: z9.string().min(1),
2667
+ replace: z9.string(),
2668
+ site_ids: z9.array(z9.string()).optional().describe("Subset of sites to hit. Defaults to all."),
2669
+ widget_type: z9.string().optional(),
2670
+ case_sensitive: z9.boolean().default(false),
2671
+ confirmation: z9.string().optional()
2672
+ }),
2673
+ outputSchema: z9.object({
2674
+ mode: z9.enum(["dry_run", "applied"]),
2675
+ sites_scanned: z9.number(),
2676
+ total_match_count: z9.number(),
2677
+ by_site: z9.array(z9.object({
2678
+ site_id: z9.string(),
2679
+ url: z9.string(),
2680
+ pages_scanned: z9.number(),
2681
+ matches: z9.number(),
2682
+ error: z9.string().optional()
2683
+ })),
2684
+ confirmation_token: z9.string().optional(),
2685
+ expires_in_seconds: z9.number().optional()
2686
+ }),
2687
+ annotations: { destructiveHint: true, idempotentHint: false, openWorldHint: true },
2688
+ async handler(input) {
2689
+ const cfg = loadConfig();
2690
+ const targets = input.site_ids ? cfg.sites.filter((s) => input.site_ids?.includes(s.id)) : cfg.sites;
2691
+ const by_site = [];
2692
+ let total = 0;
2693
+ for (const site of targets) {
2694
+ try {
2695
+ const pages = await listElementorPageIds(site.id);
2696
+ let siteMatches = 0;
2697
+ for (const p of pages) {
2698
+ try {
2699
+ const page = await wpRequest(`/wp/v2/pages/${p.id}?context=edit&_fields=meta`, { siteId: site.id });
2700
+ const v = page.meta?._elementor_data;
2701
+ const raw = typeof v === "string" ? v : JSON.stringify(v ?? []);
2702
+ const data = parseElementorData(raw);
2703
+ const dry = findReplaceInWidgets(JSON.parse(JSON.stringify(data)), input.find, input.replace, {
2704
+ widgetType: input.widget_type,
2705
+ caseSensitive: input.case_sensitive
2706
+ });
2707
+ siteMatches += dry.replacementCount;
2708
+ } catch {
2709
+ }
2710
+ }
2711
+ by_site.push({ site_id: site.id, url: site.url, pages_scanned: pages.length, matches: siteMatches });
2712
+ total += siteMatches;
2713
+ } catch (e) {
2714
+ by_site.push({ site_id: site.id, url: site.url, pages_scanned: 0, matches: 0, error: e.message });
2715
+ }
2716
+ }
2717
+ if (!input.confirmation) {
2718
+ if (total === 0) {
2719
+ return {
2720
+ mode: "dry_run",
2721
+ sites_scanned: by_site.length,
2722
+ total_match_count: 0,
2723
+ by_site
2724
+ };
2725
+ }
2726
+ const token = issueConfirmation("fleet_find_replace", { find: input.find, replace: input.replace }, POLICIES.CONFIRMATION_TTL_SECONDS);
2727
+ return {
2728
+ mode: "dry_run",
2729
+ sites_scanned: by_site.length,
2730
+ total_match_count: total,
2731
+ by_site,
2732
+ confirmation_token: token,
2733
+ expires_in_seconds: POLICIES.CONFIRMATION_TTL_SECONDS
2734
+ };
2735
+ }
2736
+ const conf = consumeConfirmation(input.confirmation, "fleet_find_replace");
2737
+ if (!conf) throw new Error("Invalid or expired confirmation token");
2738
+ for (const site of targets) {
2739
+ const pages = await listElementorPageIds(site.id);
2740
+ for (const p of pages) {
2741
+ try {
2742
+ const page = await wpRequest(`/wp/v2/pages/${p.id}?context=edit&_fields=meta`, { siteId: site.id });
2743
+ const v = page.meta?._elementor_data;
2744
+ const raw = typeof v === "string" ? v : JSON.stringify(v ?? []);
2745
+ const data = parseElementorData(raw);
2746
+ const r = findReplaceInWidgets(data, input.find, input.replace, { widgetType: input.widget_type, caseSensitive: input.case_sensitive });
2747
+ if (r.replacementCount === 0) continue;
2748
+ const ser = serializeElementorData(data);
2749
+ const validation = validateElementorData(ser);
2750
+ if (!validation.valid) continue;
2751
+ await fullBackup(site.id, p.id);
2752
+ await wpRequest(`/wp/v2/pages/${p.id}`, {
2753
+ siteId: site.id,
2754
+ method: "PUT",
2755
+ body: { meta: { _elementor_data: ser } }
2756
+ });
2757
+ await flushCSS(site.id, p.id);
2758
+ } catch {
2759
+ }
2760
+ }
2761
+ }
2762
+ return {
2763
+ mode: "applied",
2764
+ sites_scanned: by_site.length,
2765
+ total_match_count: total,
2766
+ by_site
2767
+ };
2768
+ }
2769
+ });
2770
+ var restoreFromFileTool = defineTool({
2771
+ name: "restore_from_file",
2772
+ description: "Restore a page from a JSON backup file (created by ANY earlier op with backup_to_file=true or by direct fullBackup with to_file). Requires the file_path returned by that backup. Two-call confirmation.",
2773
+ inputSchema: z9.object({
2774
+ site_id: z9.string().optional(),
2775
+ page_id: z9.number().int().positive(),
2776
+ file_path: z9.string().min(1),
2777
+ confirmation: z9.string().optional()
2778
+ }),
2779
+ outputSchema: z9.object({
2780
+ mode: z9.enum(["dry_run", "restored"]),
2781
+ page_id: z9.number(),
2782
+ file_path: z9.string(),
2783
+ method: z9.enum(["wp-cli", "rest"]).optional(),
2784
+ pre_restore_backup_meta_key: z9.string().optional(),
2785
+ css_flush: z9.string().optional(),
2786
+ confirmation_token: z9.string().optional()
2787
+ }),
2788
+ annotations: { destructiveHint: true, idempotentHint: false, openWorldHint: true },
2789
+ async handler(input) {
2790
+ if (!input.confirmation) {
2791
+ const token = issueConfirmation("restore_from_file", input, POLICIES.CONFIRMATION_TTL_SECONDS);
2792
+ return {
2793
+ mode: "dry_run",
2794
+ page_id: input.page_id,
2795
+ file_path: input.file_path,
2796
+ confirmation_token: token
2797
+ };
2798
+ }
2799
+ const conf = consumeConfirmation(input.confirmation, "restore_from_file");
2800
+ if (!conf) throw new Error("Invalid or expired confirmation token");
2801
+ const pre = await fullBackup(input.site_id, input.page_id);
2802
+ const r = await restoreFromFile(input.site_id, input.page_id, input.file_path);
2803
+ const flush = await flushCSS(input.site_id, input.page_id);
2804
+ return {
2805
+ mode: "restored",
2806
+ page_id: input.page_id,
2807
+ file_path: input.file_path,
2808
+ method: r.method,
2809
+ pre_restore_backup_meta_key: pre.meta_key,
2810
+ css_flush: flush.method
2811
+ };
2812
+ }
2813
+ });
2814
+
2815
+ // src/tools/index.ts
2816
+ var tools = [
2817
+ // Sites & health
2818
+ listSitesTool,
2819
+ pingSiteTool,
2820
+ siteHealthTool,
2821
+ // Pages
2822
+ listElementorPagesTool,
2823
+ readPageElementorTool,
2824
+ listWidgetsInPageTool,
2825
+ listGlobalWidgetsTool,
2826
+ preflightCheckTool,
2827
+ findReplaceTool,
2828
+ listElementorBackupsTool,
2829
+ restoreElementorBackupTool,
2830
+ duplicateElementorPageTool,
2831
+ // Widget-level CRUD (v1.1)
2832
+ readWidgetTool,
2833
+ updateWidgetSettingsTool,
2834
+ deleteWidgetTool,
2835
+ duplicateWidgetTool,
2836
+ swapWidgetTypeTool,
2837
+ addWidgetTool,
2838
+ moveWidgetTool,
2839
+ // Templates
2840
+ listTemplatesTool,
2841
+ exportTemplateTool,
2842
+ importTemplateTool,
2843
+ applyTemplateToPageTool,
2844
+ // Bulk + fleet (v1.1)
2845
+ bulkFindReplaceSiteTool,
2846
+ fleetFindReplaceTool,
2847
+ restoreFromFileTool,
2848
+ // WP-CLI escape
2849
+ wpCliRunTool,
2850
+ wpSearchReplaceTool,
2851
+ wpElementorFlushCssTool,
2852
+ wpPluginListTool,
2853
+ wpPluginUpdateTool,
2854
+ // Visual
2855
+ screenshotPageTool,
2856
+ compareScreenshotsTool,
2857
+ // Versions
2858
+ checkElementorVersionsTool
2859
+ ];
2860
+
2861
+ // src/server.ts
2862
+ init_logger();
850
2863
 
851
2864
  // src/resources/index.ts
852
- import { readFileSync as readFileSync2, existsSync, readdirSync } from "fs";
853
- import { dirname, resolve, join, basename } from "path";
2865
+ import { readFileSync as readFileSync4, existsSync as existsSync3, readdirSync } from "fs";
2866
+ import { dirname, resolve, join as join3, basename } from "path";
854
2867
  import { fileURLToPath } from "url";
855
2868
  var __dirname = dirname(fileURLToPath(import.meta.url));
856
2869
  var DOCS_DIR = resolve(__dirname, "../resources/elementor-docs");
857
2870
  async function listResources() {
858
- if (!existsSync(DOCS_DIR)) return [];
2871
+ if (!existsSync3(DOCS_DIR)) return [];
859
2872
  const files = readdirSync(DOCS_DIR).filter((f) => f.endsWith(".md"));
860
2873
  return files.map((f) => ({
861
2874
  uri: `elementor-docs://${f}`,
@@ -869,14 +2882,14 @@ async function readResource(uri) {
869
2882
  throw new Error(`Unknown resource URI: ${uri}`);
870
2883
  }
871
2884
  const filename = uri.replace("elementor-docs://", "");
872
- const path = join(DOCS_DIR, filename);
873
- if (!existsSync(path)) throw new Error(`Resource not found: ${filename}`);
2885
+ const path = join3(DOCS_DIR, filename);
2886
+ if (!existsSync3(path)) throw new Error(`Resource not found: ${filename}`);
874
2887
  return {
875
2888
  contents: [
876
2889
  {
877
2890
  uri,
878
2891
  mimeType: "text/markdown",
879
- text: readFileSync2(path, "utf8")
2892
+ text: readFileSync4(path, "utf8")
880
2893
  }
881
2894
  ]
882
2895
  };
@@ -884,7 +2897,7 @@ async function readResource(uri) {
884
2897
 
885
2898
  // src/server.ts
886
2899
  var __dirname2 = dirname2(fileURLToPath2(import.meta.url));
887
- var pkg = JSON.parse(readFileSync3(resolve2(__dirname2, "../package.json"), "utf8"));
2900
+ var pkg = JSON.parse(readFileSync5(resolve2(__dirname2, "../package.json"), "utf8"));
888
2901
  function toMcpInputSchema(zod) {
889
2902
  return zodToJsonSchema(zod, { target: "jsonSchema7", $refStrategy: "none" });
890
2903
  }