@adsim/wordpress-mcp-server 5.4.0 → 5.5.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.
@@ -1,8 +1,8 @@
1
1
  <?php
2
2
  /**
3
3
  * Plugin Name: MCP Diagnostics Companion
4
- * Description: Exposes secure REST endpoints for WordPress MCP Server diagnostics (debug log, cron events, transients, hooks).
5
- * Version: 1.0.0
4
+ * Description: Exposes secure REST endpoints for WordPress MCP Server diagnostics (debug log, cron events, transients, hooks) and Elementor link insertion.
5
+ * Version: 1.1.0
6
6
  * Requires PHP: 7.4
7
7
  * Author: AdSim
8
8
  * License: MIT
@@ -272,6 +272,75 @@ function mcp_diagnostics_register_routes() {
272
272
  ),
273
273
  ),
274
274
  ) );
275
+
276
+ // ── Elementor: read data + insert link into widget content ──
277
+ // The _elementor_* meta is protected (underscore prefix): its values are
278
+ // hidden from the REST API even with context=edit, and post_content
279
+ // writes are regenerated from _elementor_data. These endpoints are the
280
+ // only reliable read/write channel for Elementor-built pages.
281
+ register_rest_route( $namespace, '/elementor/data/(?P<post_id>\d+)', array(
282
+ 'methods' => 'GET',
283
+ 'callback' => 'mcp_elementor_data_get',
284
+ 'permission_callback' => 'mcp_diagnostics_schema_permission_check',
285
+ 'args' => array(
286
+ 'post_id' => array(
287
+ 'type' => 'integer',
288
+ 'required' => true,
289
+ 'sanitize_callback' => 'absint',
290
+ ),
291
+ ),
292
+ ) );
293
+
294
+ register_rest_route( $namespace, '/elementor/insert-link/(?P<post_id>\d+)', array(
295
+ 'methods' => 'POST',
296
+ 'callback' => 'mcp_elementor_insert_link',
297
+ 'permission_callback' => 'mcp_diagnostics_schema_permission_check',
298
+ 'args' => array(
299
+ 'post_id' => array(
300
+ 'type' => 'integer',
301
+ 'required' => true,
302
+ 'sanitize_callback' => 'absint',
303
+ ),
304
+ // No sanitize_callback: the needle must match the stored widget
305
+ // HTML byte-for-byte (sanitizing would break entity/tag matching).
306
+ 'exact_text' => array(
307
+ 'type' => 'string',
308
+ 'required' => true,
309
+ ),
310
+ 'link_url' => array(
311
+ 'type' => 'string',
312
+ 'required' => true,
313
+ ),
314
+ ),
315
+ ) );
316
+
317
+ register_rest_route( $namespace, '/elementor/update-widget/(?P<post_id>\d+)', array(
318
+ 'methods' => 'POST',
319
+ 'callback' => 'mcp_elementor_update_widget',
320
+ 'permission_callback' => 'mcp_diagnostics_schema_permission_check',
321
+ 'args' => array(
322
+ 'post_id' => array(
323
+ 'type' => 'integer',
324
+ 'required' => true,
325
+ 'sanitize_callback' => 'absint',
326
+ ),
327
+ 'widget_id' => array(
328
+ 'type' => 'string',
329
+ 'required' => true,
330
+ 'sanitize_callback' => 'sanitize_text_field',
331
+ ),
332
+ 'setting_key' => array(
333
+ 'type' => 'string',
334
+ 'required' => true,
335
+ 'sanitize_callback' => 'sanitize_key',
336
+ ),
337
+ // Raw HTML value (rollback restores the exact previous content).
338
+ 'value' => array(
339
+ 'type' => 'string',
340
+ 'required' => true,
341
+ ),
342
+ ),
343
+ ) );
275
344
  }
276
345
 
277
346
  /**
@@ -1347,3 +1416,323 @@ function mcp_diagnostics_wc_abandoned_carts( $request ) {
1347
1416
  // No source available
1348
1417
  return new WP_REST_Response( array( 'available' => false ), 200 );
1349
1418
  }
1419
+
1420
+ // ============================================================
1421
+ // Elementor — data read + targeted link insertion
1422
+ // ============================================================
1423
+
1424
+ /**
1425
+ * Widget settings that hold user-visible HTML/text, per widget type.
1426
+ * Only these settings can be searched and modified. Extensible via the
1427
+ * mcp_elementor_editable_settings filter.
1428
+ */
1429
+ function mcp_elementor_editable_settings() {
1430
+ return apply_filters( 'mcp_elementor_editable_settings', array(
1431
+ 'text-editor' => array( 'editor' ),
1432
+ ) );
1433
+ }
1434
+
1435
+ /**
1436
+ * Load and validate the Elementor context for a post.
1437
+ * Returns array( post, elements ) or WP_Error.
1438
+ */
1439
+ function mcp_elementor_load_context( $post_id, $require_write = false ) {
1440
+ $post = get_post( $post_id );
1441
+ if ( ! $post ) {
1442
+ return new WP_Error( 'not_found', __( 'Post not found.' ), array( 'status' => 404 ) );
1443
+ }
1444
+ if ( $require_write && ! current_user_can( 'edit_post', $post_id ) ) {
1445
+ return new WP_Error( 'rest_forbidden', __( 'You cannot edit this post.' ), array( 'status' => 403 ) );
1446
+ }
1447
+ if ( ! class_exists( '\Elementor\Plugin' ) ) {
1448
+ return new WP_Error( 'elementor_inactive', __( 'Elementor is not active on this site.' ), array( 'status' => 501 ) );
1449
+ }
1450
+ if ( get_post_meta( $post_id, '_elementor_edit_mode', true ) !== 'builder' ) {
1451
+ return new WP_Error( 'not_elementor', __( 'This post is not built with Elementor.' ), array( 'status' => 409 ) );
1452
+ }
1453
+ $raw = get_post_meta( $post_id, '_elementor_data', true );
1454
+ if ( empty( $raw ) ) {
1455
+ return new WP_Error( 'no_elementor_data', __( 'No Elementor data found for this post.' ), array( 'status' => 409 ) );
1456
+ }
1457
+ $elements = is_array( $raw ) ? $raw : json_decode( $raw, true );
1458
+ if ( ! is_array( $elements ) ) {
1459
+ return new WP_Error( 'corrupt_elementor_data', __( 'Elementor data is not valid JSON.' ), array( 'status' => 500 ) );
1460
+ }
1461
+ return array( 'post' => $post, 'elements' => $elements );
1462
+ }
1463
+
1464
+ /**
1465
+ * Recursively collect occurrences of $needle inside editable widget settings.
1466
+ * Each match: widget_id, widget_type, setting_key, pos, html, path (list of
1467
+ * child indices from the root, each level descending through ['elements']).
1468
+ */
1469
+ function mcp_elementor_find_occurrences( $elements, $needle, $path = array() ) {
1470
+ $editable = mcp_elementor_editable_settings();
1471
+ $matches = array();
1472
+
1473
+ foreach ( $elements as $i => $el ) {
1474
+ if ( ! is_array( $el ) ) {
1475
+ continue;
1476
+ }
1477
+ $el_path = array_merge( $path, array( $i ) );
1478
+ $widget_type = isset( $el['widgetType'] ) ? $el['widgetType'] : null;
1479
+
1480
+ if ( $widget_type && isset( $editable[ $widget_type ] ) ) {
1481
+ foreach ( $editable[ $widget_type ] as $key ) {
1482
+ $html = isset( $el['settings'][ $key ] ) && is_string( $el['settings'][ $key ] ) ? $el['settings'][ $key ] : '';
1483
+ if ( '' === $html ) {
1484
+ continue;
1485
+ }
1486
+ $offset = 0;
1487
+ while ( false !== ( $pos = strpos( $html, $needle, $offset ) ) ) {
1488
+ $matches[] = array(
1489
+ 'widget_id' => isset( $el['id'] ) ? $el['id'] : '',
1490
+ 'widget_type' => $widget_type,
1491
+ 'setting_key' => $key,
1492
+ 'pos' => $pos,
1493
+ 'html' => $html,
1494
+ 'path' => $el_path,
1495
+ );
1496
+ $offset = $pos + strlen( $needle );
1497
+ }
1498
+ }
1499
+ }
1500
+
1501
+ if ( ! empty( $el['elements'] ) && is_array( $el['elements'] ) ) {
1502
+ $matches = array_merge( $matches, mcp_elementor_find_occurrences( $el['elements'], $needle, $el_path ) );
1503
+ }
1504
+ }
1505
+
1506
+ return $matches;
1507
+ }
1508
+
1509
+ /**
1510
+ * Apply $callback (by reference) to the element addressed by $path.
1511
+ */
1512
+ function mcp_elementor_apply_at_path( &$elements, $path, $callback ) {
1513
+ $ref = &$elements;
1514
+ foreach ( $path as $depth => $index ) {
1515
+ if ( $depth > 0 ) {
1516
+ $ref = &$ref['elements'];
1517
+ }
1518
+ if ( ! isset( $ref[ $index ] ) ) {
1519
+ return false;
1520
+ }
1521
+ $ref = &$ref[ $index ];
1522
+ }
1523
+ $callback( $ref );
1524
+ return true;
1525
+ }
1526
+
1527
+ /**
1528
+ * True when the occurrence at $pos in $html sits inside an existing <a> tag.
1529
+ */
1530
+ function mcp_elementor_inside_link( $html, $pos ) {
1531
+ $before = substr( $html, 0, $pos );
1532
+ $open_pos = strripos( $before, '<a' );
1533
+ $close_pos = strripos( $before, '</a>' );
1534
+ return ( false !== $open_pos ) && ( false === $close_pos || $close_pos < $open_pos );
1535
+ }
1536
+
1537
+ /**
1538
+ * Context snippet around an insertion point (for audit / preview).
1539
+ */
1540
+ function mcp_elementor_snippet( $html, $pos, $len, $context = 80 ) {
1541
+ $start = max( 0, $pos - $context );
1542
+ return substr( $html, $start, ( $pos - $start ) + $len + $context );
1543
+ }
1544
+
1545
+ /**
1546
+ * Persist modified elements through Elementor's document API (regenerates
1547
+ * CSS and fires Elementor save hooks). Returns true or WP_Error.
1548
+ */
1549
+ function mcp_elementor_save_elements( $post_id, $elements ) {
1550
+ try {
1551
+ $document = \Elementor\Plugin::$instance->documents->get( $post_id, false );
1552
+ if ( ! $document ) {
1553
+ return new WP_Error( 'document_unavailable', __( 'Elementor document could not be loaded.' ), array( 'status' => 500 ) );
1554
+ }
1555
+ $saved = $document->save( array( 'elements' => $elements ) );
1556
+ if ( ! $saved ) {
1557
+ return new WP_Error( 'save_failed', __( 'Elementor refused to save the document.' ), array( 'status' => 500 ) );
1558
+ }
1559
+ } catch ( \Throwable $e ) {
1560
+ return new WP_Error( 'save_exception', $e->getMessage(), array( 'status' => 500 ) );
1561
+ }
1562
+ // Force CSS regeneration on next render.
1563
+ delete_post_meta( $post_id, '_elementor_css' );
1564
+ return true;
1565
+ }
1566
+
1567
+ /**
1568
+ * GET /mcp-diagnostics/v1/elementor/data/{post_id}
1569
+ * Reliable read channel for _elementor_data (hidden from the REST API).
1570
+ */
1571
+ function mcp_elementor_data_get( $request ) {
1572
+ $post_id = $request->get_param( 'post_id' );
1573
+ $post = get_post( $post_id );
1574
+
1575
+ if ( ! $post ) {
1576
+ return new WP_Error( 'not_found', __( 'Post not found.' ), array( 'status' => 404 ) );
1577
+ }
1578
+
1579
+ $raw = get_post_meta( $post_id, '_elementor_data', true );
1580
+ $elements = is_array( $raw ) ? $raw : ( $raw ? json_decode( $raw, true ) : null );
1581
+
1582
+ return new WP_REST_Response( array(
1583
+ 'post_id' => $post_id,
1584
+ 'edit_mode' => get_post_meta( $post_id, '_elementor_edit_mode', true ) ?: null,
1585
+ 'elements' => is_array( $elements ) ? $elements : array(),
1586
+ ), 200 );
1587
+ }
1588
+
1589
+ /**
1590
+ * POST /mcp-diagnostics/v1/elementor/insert-link/{post_id}
1591
+ * Wrap ONE exact occurrence of exact_text with <a href="link_url"> inside an
1592
+ * editable widget. Refuses on: text not found, ambiguous (multiple
1593
+ * occurrences), or already inside a link.
1594
+ */
1595
+ function mcp_elementor_insert_link( $request ) {
1596
+ if ( mcp_diagnostics_is_read_only() ) {
1597
+ return new WP_Error( 'read_only', __( 'Site is in read-only mode.' ), array( 'status' => 403 ) );
1598
+ }
1599
+
1600
+ $post_id = $request->get_param( 'post_id' );
1601
+ $exact_text = (string) $request->get_param( 'exact_text' );
1602
+ $link_url = esc_url_raw( (string) $request->get_param( 'link_url' ) );
1603
+
1604
+ if ( '' === trim( $exact_text ) || strlen( $exact_text ) > 500 ) {
1605
+ return new WP_Error( 'invalid_text', __( 'exact_text must be a non-empty string of at most 500 characters.' ), array( 'status' => 400 ) );
1606
+ }
1607
+ if ( '' === $link_url || ! preg_match( '#^https?://#i', $link_url ) ) {
1608
+ return new WP_Error( 'invalid_url', __( 'link_url must be a valid http(s) URL.' ), array( 'status' => 400 ) );
1609
+ }
1610
+
1611
+ $ctx = mcp_elementor_load_context( $post_id, true );
1612
+ if ( is_wp_error( $ctx ) ) {
1613
+ return $ctx;
1614
+ }
1615
+ $elements = $ctx['elements'];
1616
+
1617
+ $matches = mcp_elementor_find_occurrences( $elements, $exact_text );
1618
+ if ( 0 === count( $matches ) ) {
1619
+ return new WP_Error( 'text_not_found', __( 'exact_text was not found in any editable Elementor widget.' ), array( 'status' => 404 ) );
1620
+ }
1621
+ if ( count( $matches ) > 1 ) {
1622
+ return new WP_Error( 'ambiguous_text', sprintf(
1623
+ /* translators: %d: occurrence count */
1624
+ __( 'exact_text appears %d times — cannot insert unambiguously.' ),
1625
+ count( $matches )
1626
+ ), array( 'status' => 409, 'occurrences' => count( $matches ) ) );
1627
+ }
1628
+
1629
+ $m = $matches[0];
1630
+ if ( mcp_elementor_inside_link( $m['html'], $m['pos'] ) ) {
1631
+ return new WP_Error( 'already_linked', __( 'exact_text is already inside an <a> tag.' ), array( 'status' => 409 ) );
1632
+ }
1633
+
1634
+ $linked = '<a href="' . esc_url( $link_url ) . '">' . $exact_text . '</a>';
1635
+ $new_html = substr_replace( $m['html'], $linked, $m['pos'], strlen( $exact_text ) );
1636
+
1637
+ $setting_key = $m['setting_key'];
1638
+ $applied = mcp_elementor_apply_at_path( $elements, $m['path'], function ( &$el ) use ( $setting_key, $new_html ) {
1639
+ $el['settings'][ $setting_key ] = $new_html;
1640
+ } );
1641
+ if ( ! $applied ) {
1642
+ return new WP_Error( 'apply_failed', __( 'Could not address the target widget in the Elementor tree.' ), array( 'status' => 500 ) );
1643
+ }
1644
+
1645
+ $saved = mcp_elementor_save_elements( $post_id, $elements );
1646
+ if ( is_wp_error( $saved ) ) {
1647
+ return $saved;
1648
+ }
1649
+
1650
+ return new WP_REST_Response( array(
1651
+ 'post_id' => $post_id,
1652
+ 'status' => 'link_inserted',
1653
+ 'widget_id' => $m['widget_id'],
1654
+ 'widget_type' => $m['widget_type'],
1655
+ 'setting_key' => $m['setting_key'],
1656
+ 'link_url' => $link_url,
1657
+ 'anchor_text' => $exact_text,
1658
+ 'snippet_before' => mcp_elementor_snippet( $m['html'], $m['pos'], strlen( $exact_text ) ),
1659
+ 'snippet_after' => mcp_elementor_snippet( $new_html, $m['pos'], strlen( $linked ) ),
1660
+ 'rollback' => array(
1661
+ 'widget_id' => $m['widget_id'],
1662
+ 'setting_key' => $m['setting_key'],
1663
+ 'previous_value' => $m['html'],
1664
+ ),
1665
+ ), 200 );
1666
+ }
1667
+
1668
+ /**
1669
+ * POST /mcp-diagnostics/v1/elementor/update-widget/{post_id}
1670
+ * Set one editable setting of one widget (rollback primitive for
1671
+ * insert-link: restore rollback.previous_value).
1672
+ */
1673
+ function mcp_elementor_update_widget( $request ) {
1674
+ if ( mcp_diagnostics_is_read_only() ) {
1675
+ return new WP_Error( 'read_only', __( 'Site is in read-only mode.' ), array( 'status' => 403 ) );
1676
+ }
1677
+
1678
+ $post_id = $request->get_param( 'post_id' );
1679
+ $widget_id = (string) $request->get_param( 'widget_id' );
1680
+ $setting_key = (string) $request->get_param( 'setting_key' );
1681
+ $value = (string) $request->get_param( 'value' );
1682
+
1683
+ $ctx = mcp_elementor_load_context( $post_id, true );
1684
+ if ( is_wp_error( $ctx ) ) {
1685
+ return $ctx;
1686
+ }
1687
+ $elements = $ctx['elements'];
1688
+
1689
+ // Locate the widget and check the setting is editable for its type.
1690
+ $editable = mcp_elementor_editable_settings();
1691
+ $found = null;
1692
+ $walk = function ( $els, $path ) use ( &$walk, &$found, $widget_id ) {
1693
+ foreach ( $els as $i => $el ) {
1694
+ if ( ! is_array( $el ) || $found ) {
1695
+ continue;
1696
+ }
1697
+ $el_path = array_merge( $path, array( $i ) );
1698
+ if ( isset( $el['id'] ) && $el['id'] === $widget_id ) {
1699
+ $found = array( 'el' => $el, 'path' => $el_path );
1700
+ return;
1701
+ }
1702
+ if ( ! empty( $el['elements'] ) && is_array( $el['elements'] ) ) {
1703
+ $walk( $el['elements'], $el_path );
1704
+ }
1705
+ }
1706
+ };
1707
+ $walk( $elements, array() );
1708
+
1709
+ if ( ! $found ) {
1710
+ return new WP_Error( 'widget_not_found', __( 'No element with this widget_id.' ), array( 'status' => 404 ) );
1711
+ }
1712
+ $widget_type = isset( $found['el']['widgetType'] ) ? $found['el']['widgetType'] : null;
1713
+ if ( ! $widget_type || ! isset( $editable[ $widget_type ] ) || ! in_array( $setting_key, $editable[ $widget_type ], true ) ) {
1714
+ return new WP_Error( 'setting_not_editable', __( 'This setting is not editable for this widget type.' ), array( 'status' => 400 ) );
1715
+ }
1716
+
1717
+ $previous = isset( $found['el']['settings'][ $setting_key ] ) ? $found['el']['settings'][ $setting_key ] : '';
1718
+ $applied = mcp_elementor_apply_at_path( $elements, $found['path'], function ( &$el ) use ( $setting_key, $value ) {
1719
+ $el['settings'][ $setting_key ] = $value;
1720
+ } );
1721
+ if ( ! $applied ) {
1722
+ return new WP_Error( 'apply_failed', __( 'Could not address the target widget in the Elementor tree.' ), array( 'status' => 500 ) );
1723
+ }
1724
+
1725
+ $saved = mcp_elementor_save_elements( $post_id, $elements );
1726
+ if ( is_wp_error( $saved ) ) {
1727
+ return $saved;
1728
+ }
1729
+
1730
+ return new WP_REST_Response( array(
1731
+ 'post_id' => $post_id,
1732
+ 'status' => 'widget_updated',
1733
+ 'widget_id' => $widget_id,
1734
+ 'widget_type' => $widget_type,
1735
+ 'setting_key' => $setting_key,
1736
+ 'previous_value' => is_string( $previous ) ? $previous : '',
1737
+ ), 200 );
1738
+ }
package/index.js CHANGED
@@ -22,6 +22,7 @@ import { calculateReadabilityScore, extractHeadingsOutline, detectContentSection
22
22
  import { detectSeoPlugin, getRenderedHead, parseRenderedHead } from './src/pluginDetector.js';
23
23
  import { PluginRegistry } from './src/plugins/registry.js';
24
24
  import { acfAdapter } from './src/plugins/adapters/acf/acfAdapter.js';
25
+ import { elementorAdapter } from './src/plugins/adapters/elementor/elementorAdapter.js';
25
26
  import { rt, initRuntime } from './src/shared/context.js';
26
27
  import { getHandler, getTools, ALL_DEFINITIONS } from './src/tools/index.js';
27
28
  import { json, strip, buildPaginationMeta, validateBlocks } from './src/shared/utils.js';
@@ -29,7 +30,7 @@ import { validateInput } from './src/shared/governance.js';
29
30
 
30
31
  // ── Configuration ──────────────────────────────────────────────
31
32
 
32
- const VERSION = '5.0.0';
33
+ const VERSION = '5.5.0';
33
34
  const VERBOSE = process.env.WP_MCP_VERBOSE === 'true' || process.argv.includes('--verbose');
34
35
  const MAX_RETRIES = parseInt(process.env.WP_MCP_MAX_RETRIES || '3', 10);
35
36
  const TIMEOUT_MS = parseInt(process.env.WP_MCP_TIMEOUT || '30000', 10);
@@ -60,7 +61,7 @@ function checkRateLimit() {
60
61
  }
61
62
 
62
63
  function enforceReadOnly(toolName) {
63
- const writeTools = ['wp_create_post', 'wp_update_post', 'wp_delete_post', 'wp_create_page', 'wp_update_page', 'wp_upload_media', 'wp_create_comment', 'wp_create_taxonomy_term', 'wp_update_seo_meta', 'wp_activate_plugin', 'wp_deactivate_plugin', 'wp_restore_revision', 'wp_delete_revision', 'wp_submit_for_review', 'wp_approve_post', 'wp_reject_post', 'wp_create_staging_draft', 'wp_merge_staging_to_live', 'wp_discard_staging_draft', 'wc_list_products', 'wc_get_product', 'wc_list_orders', 'wc_get_order', 'wc_list_customers', 'wc_inventory_alert', 'wc_order_intelligence', 'wc_seo_product_audit', 'wc_suggest_product_links', 'wc_update_product', 'wc_update_stock', 'wc_update_order_status', 'wp_create_template', 'wp_update_template', 'wp_delete_template', 'wp_create_template_part', 'wp_update_template_part', 'wp_delete_template_part', 'wp_update_global_styles', 'wp_create_block_pattern', 'wp_delete_block_pattern', 'wp_create_navigation_menu', 'wp_update_navigation_menu', 'wp_delete_navigation_menu', 'wp_update_widget', 'wp_delete_widget', 'wp_create_user', 'wp_update_user', 'wp_delete_user', 'wp_reset_user_password', 'wp_revoke_application_password', 'wp_bulk_update'];
64
+ const writeTools = ['wp_create_post', 'wp_update_post', 'wp_delete_post', 'wp_create_page', 'wp_update_page', 'wp_upload_media', 'wp_create_comment', 'wp_create_taxonomy_term', 'wp_update_seo_meta', 'wp_activate_plugin', 'wp_deactivate_plugin', 'wp_restore_revision', 'wp_delete_revision', 'wp_submit_for_review', 'wp_approve_post', 'wp_reject_post', 'wp_create_staging_draft', 'wp_merge_staging_to_live', 'wp_discard_staging_draft', 'wc_list_products', 'wc_get_product', 'wc_list_orders', 'wc_get_order', 'wc_list_customers', 'wc_inventory_alert', 'wc_order_intelligence', 'wc_seo_product_audit', 'wc_suggest_product_links', 'wc_update_product', 'wc_update_stock', 'wc_update_order_status', 'wp_create_template', 'wp_update_template', 'wp_delete_template', 'wp_create_template_part', 'wp_update_template_part', 'wp_delete_template_part', 'wp_update_global_styles', 'wp_create_block_pattern', 'wp_delete_block_pattern', 'wp_create_navigation_menu', 'wp_update_navigation_menu', 'wp_delete_navigation_menu', 'wp_update_widget', 'wp_delete_widget', 'wp_create_user', 'wp_update_user', 'wp_delete_user', 'wp_reset_user_password', 'wp_revoke_application_password', 'wp_bulk_update', 'elementor_insert_link', 'elementor_update_widget'];
64
65
  if (getActiveControls().read_only && writeTools.includes(toolName))
65
66
  throw new Error(`Blocked: Server is in READ-ONLY mode (WP_READ_ONLY=true). Tool "${toolName}" is not allowed.`);
66
67
  }
@@ -325,7 +326,7 @@ async function healthCheck() {
325
326
  // ── Plugin layer ───────────────────────────────────────────────
326
327
 
327
328
  const pluginRegistry = new PluginRegistry();
328
- const PLUGIN_ADAPTERS = [acfAdapter];
329
+ const PLUGIN_ADAPTERS = [acfAdapter, elementorAdapter];
329
330
 
330
331
  async function initializePluginLayer() {
331
332
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adsim/wordpress-mcp-server",
3
- "version": "5.4.0",
3
+ "version": "5.5.0",
4
4
  "description": "Enterprise WordPress MCP Server — 180 tools, modular architecture, governance, audit trail, WooCommerce, Schema.org, multilingual.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -1,17 +1,26 @@
1
1
  /**
2
- * Elementor Adapter — read-only.
2
+ * Elementor Adapter.
3
3
  *
4
- * Provides 3 tools for reading Elementor data via the REST API (elementor/v1):
4
+ * Read tools (REST API elementor/v1, fallback MCP Companion):
5
5
  * - elementor_list_templates List templates (page, section, block, popup)
6
6
  * - elementor_get_template Get full template content and elements
7
7
  * - elementor_get_page_data Get Elementor editor data for a post/page
8
8
  *
9
- * All tools are read-only (riskLevel: "low") and pass responses through
10
- * contextGuard to prevent oversized payloads from consuming LLM context.
9
+ * Write tools (require the MCP Companion mu-plugin 1.1 the _elementor_*
10
+ * meta is protected and post_content writes are regenerated from
11
+ * _elementor_data, so the companion is the only reliable write channel):
12
+ * - elementor_insert_link Wrap ONE exact text occurrence with <a href>
13
+ * - elementor_update_widget Set one widget setting (rollback primitive)
14
+ *
15
+ * All responses pass through contextGuard to prevent oversized payloads.
11
16
  */
12
17
 
13
18
  import { applyContextGuard } from '../../contextGuard.js';
14
19
 
20
+ const COMPANION_BASE = '/wp-json/mcp-diagnostics/v1';
21
+ const COMPANION_FIX =
22
+ 'Install or update the MCP Companion plugin (companion/mcp-diagnostics.php, version ≥ 1.1) as a must-use plugin: copy it to wp-content/mu-plugins/mcp-diagnostics.php and retry.';
23
+
15
24
  // ── Helpers ─────────────────────────────────────────────────────────────
16
25
 
17
26
  function json(data) {
@@ -87,9 +96,16 @@ async function handleGetPageData(args, apiRequest) {
87
96
  try {
88
97
  data = await apiRequest(`/document/${post_id}`, { basePath: '/wp-json/elementor/v1' });
89
98
  } catch {
90
- // Fallback: read post meta _elementor_data via WP REST API
91
- const post = await apiRequest(`/posts/${post_id}`, { basePath: '/wp-json/wp/v2' });
92
- data = post?.meta?._elementor_data ?? null;
99
+ try {
100
+ // Fallback 1: MCP Companion reliable read of the protected
101
+ // _elementor_data meta (hidden from the REST API even in edit context).
102
+ const companion = await apiRequest(`/elementor/data/${post_id}`, { basePath: COMPANION_BASE });
103
+ data = companion?.elements ?? null;
104
+ } catch {
105
+ // Fallback 2: post meta via WP REST API (usually empty — protected meta)
106
+ const post = await apiRequest(`/posts/${post_id}`, { basePath: '/wp-json/wp/v2' });
107
+ data = post?.meta?._elementor_data ?? null;
108
+ }
93
109
  }
94
110
 
95
111
  // Parse elements to extract widget info
@@ -120,6 +136,77 @@ async function handleGetPageData(args, apiRequest) {
120
136
  return json(guarded);
121
137
  }
122
138
 
139
+ async function handleInsertLink(args, apiRequest) {
140
+ const t0 = Date.now();
141
+ const { post_id, exact_text, link_url } = args;
142
+
143
+ if (typeof post_id !== 'number' || !Number.isInteger(post_id) || post_id <= 0) {
144
+ throw new Error('Validation error: "post_id" must be a positive integer');
145
+ }
146
+ if (typeof exact_text !== 'string' || exact_text.trim().length === 0 || exact_text.length > 500) {
147
+ throw new Error('Validation error: "exact_text" must be a non-empty string of at most 500 characters');
148
+ }
149
+ if (typeof link_url !== 'string' || !/^https?:\/\//i.test(link_url)) {
150
+ throw new Error('Validation error: "link_url" must be a valid http(s) URL');
151
+ }
152
+
153
+ let data;
154
+ try {
155
+ data = await apiRequest(`/elementor/insert-link/${post_id}`, {
156
+ basePath: COMPANION_BASE,
157
+ method: 'POST',
158
+ body: JSON.stringify({ exact_text, link_url }),
159
+ });
160
+ } catch (error) {
161
+ if (/rest_no_route/.test(error.message)) {
162
+ throw new Error(`Elementor link insertion requires the MCP Companion plugin. ${COMPANION_FIX}`);
163
+ }
164
+ throw error;
165
+ }
166
+
167
+ const guarded = applyContextGuard(data, { toolName: 'elementor_insert_link', mode: 'compact', maxChars: 50000 });
168
+
169
+ auditLog({ tool: 'elementor_insert_link', action: 'insert_link', target: post_id, status: 'success', latency_ms: Date.now() - t0, params: { link_url, widget_id: data?.widget_id ?? null } });
170
+ return json(guarded);
171
+ }
172
+
173
+ async function handleUpdateWidget(args, apiRequest) {
174
+ const t0 = Date.now();
175
+ const { post_id, widget_id, setting_key, value } = args;
176
+
177
+ if (typeof post_id !== 'number' || !Number.isInteger(post_id) || post_id <= 0) {
178
+ throw new Error('Validation error: "post_id" must be a positive integer');
179
+ }
180
+ if (typeof widget_id !== 'string' || widget_id.trim().length === 0) {
181
+ throw new Error('Validation error: "widget_id" must be a non-empty string');
182
+ }
183
+ if (typeof setting_key !== 'string' || setting_key.trim().length === 0) {
184
+ throw new Error('Validation error: "setting_key" must be a non-empty string');
185
+ }
186
+ if (typeof value !== 'string') {
187
+ throw new Error('Validation error: "value" must be a string');
188
+ }
189
+
190
+ let data;
191
+ try {
192
+ data = await apiRequest(`/elementor/update-widget/${post_id}`, {
193
+ basePath: COMPANION_BASE,
194
+ method: 'POST',
195
+ body: JSON.stringify({ widget_id, setting_key, value }),
196
+ });
197
+ } catch (error) {
198
+ if (/rest_no_route/.test(error.message)) {
199
+ throw new Error(`Elementor widget update requires the MCP Companion plugin. ${COMPANION_FIX}`);
200
+ }
201
+ throw error;
202
+ }
203
+
204
+ const guarded = applyContextGuard(data, { toolName: 'elementor_update_widget', mode: 'compact', maxChars: 50000 });
205
+
206
+ auditLog({ tool: 'elementor_update_widget', action: 'update_widget', target: post_id, status: 'success', latency_ms: Date.now() - t0, params: { widget_id, setting_key } });
207
+ return json(guarded);
208
+ }
209
+
123
210
  // ── Tool definitions (MCP format) ───────────────────────────────────────
124
211
 
125
212
  const TOOLS = [
@@ -159,6 +246,35 @@ const TOOLS = [
159
246
  },
160
247
  handler: handleGetPageData,
161
248
  },
249
+ {
250
+ name: 'elementor_insert_link',
251
+ description: 'Use to insert a link into an Elementor-built page: wraps ONE exact occurrence of exact_text with <a href="link_url"> inside an editable widget (text-editor). Refuses if the text is missing, ambiguous (multiple occurrences) or already linked. Returns a rollback payload (widget_id, setting_key, previous_value) for elementor_update_widget. Write — blocked by WP_READ_ONLY. Requires the MCP Companion mu-plugin ≥ 1.1.',
252
+ inputSchema: {
253
+ type: 'object',
254
+ properties: {
255
+ post_id: { type: 'number', description: 'Post or page ID (must be built with Elementor)' },
256
+ exact_text: { type: 'string', description: 'Text already present in the page, matched byte-for-byte (max 500 chars). Becomes the anchor text.' },
257
+ link_url: { type: 'string', description: 'Absolute http(s) URL for the link' },
258
+ },
259
+ required: ['post_id', 'exact_text', 'link_url'],
260
+ },
261
+ handler: handleInsertLink,
262
+ },
263
+ {
264
+ name: 'elementor_update_widget',
265
+ description: 'Use to set one editable setting of one Elementor widget (e.g. restore rollback.previous_value returned by elementor_insert_link). Only whitelisted settings per widget type are writable. Write — blocked by WP_READ_ONLY. Requires the MCP Companion mu-plugin ≥ 1.1.',
266
+ inputSchema: {
267
+ type: 'object',
268
+ properties: {
269
+ post_id: { type: 'number', description: 'Post or page ID (must be built with Elementor)' },
270
+ widget_id: { type: 'string', description: 'Elementor element id of the widget' },
271
+ setting_key: { type: 'string', description: 'Setting to write (e.g. "editor" for text-editor widgets)' },
272
+ value: { type: 'string', description: 'Raw HTML value to store' },
273
+ },
274
+ required: ['post_id', 'widget_id', 'setting_key', 'value'],
275
+ },
276
+ handler: handleUpdateWidget,
277
+ },
162
278
  ];
163
279
 
164
280
  // ── Adapter export ──────────────────────────────────────────────────────
@@ -166,7 +282,9 @@ const TOOLS = [
166
282
  export const elementorAdapter = {
167
283
  id: 'elementor',
168
284
  namespace: 'elementor/v1',
169
- riskLevel: 'low',
285
+ // medium: elementor_insert_link / elementor_update_widget modify a single
286
+ // targeted widget; the remaining tools are read-only.
287
+ riskLevel: 'medium',
170
288
  contextConfig: {
171
289
  maxChars: 50000,
172
290
  defaultMode: 'compact',
@@ -204,3 +204,147 @@ describe('Elementor Adapter', () => {
204
204
  expect(errors).toEqual([]);
205
205
  });
206
206
  });
207
+
208
+ // =========================================================================
209
+ // Write tools — elementor_insert_link / elementor_update_widget (Companion)
210
+ // =========================================================================
211
+
212
+ /**
213
+ * Route-aware apiRequest mock: dispatches on basePath + endpoint regex.
214
+ * Unmatched routes behave like a missing REST route (rest_no_route).
215
+ */
216
+ function mockApiRouted(routes) {
217
+ const calls = [];
218
+ const fn = async (endpoint, options = {}) => {
219
+ const basePath = options.basePath || '/wp-json/wp/v2';
220
+ calls.push({ endpoint, basePath, method: options.method || 'GET', body: options.body });
221
+ for (const r of routes) {
222
+ if (r.basePath === basePath && r.match.test(endpoint)) {
223
+ if (r.error) throw new Error(r.error);
224
+ return r.data;
225
+ }
226
+ }
227
+ throw new Error('WP API 404: Not Found\n{"code":"rest_no_route","message":"No route was found"}');
228
+ };
229
+ fn.calls = calls;
230
+ return fn;
231
+ }
232
+
233
+ const COMPANION = '/wp-json/mcp-diagnostics/v1';
234
+
235
+ const mockInsertResponse = {
236
+ post_id: 42,
237
+ status: 'link_inserted',
238
+ widget_id: 'a1b2c3d',
239
+ widget_type: 'text-editor',
240
+ setting_key: 'editor',
241
+ link_url: 'https://example.com/cible',
242
+ anchor_text: 'stratégie digitale',
243
+ snippet_before: '…votre stratégie digitale mérite…',
244
+ snippet_after: '…votre <a href="https://example.com/cible">stratégie digitale</a> mérite…',
245
+ rollback: { widget_id: 'a1b2c3d', setting_key: 'editor', previous_value: '<p>votre stratégie digitale mérite mieux</p>' },
246
+ };
247
+
248
+ describe('Elementor Adapter — write tools', () => {
249
+
250
+ const insertTool = () => elementorAdapter.getTools().find(t => t.name === 'elementor_insert_link');
251
+ const updateTool = () => elementorAdapter.getTools().find(t => t.name === 'elementor_update_widget');
252
+
253
+ it('exposes the 2 write tools with WP_READ_ONLY + Companion mentions', () => {
254
+ for (const tool of [insertTool(), updateTool()]) {
255
+ expect(tool).toBeDefined();
256
+ expect(tool.description).toContain('WP_READ_ONLY');
257
+ expect(tool.description).toContain('Companion');
258
+ }
259
+ });
260
+
261
+ it('elementor_insert_link POSTs to the companion endpoint and returns the payload', async () => {
262
+ const api = mockApiRouted([
263
+ { basePath: COMPANION, match: /^\/elementor\/insert-link\/42$/, data: mockInsertResponse },
264
+ ]);
265
+ const result = await insertTool().handler(
266
+ { post_id: 42, exact_text: 'stratégie digitale', link_url: 'https://example.com/cible' },
267
+ api
268
+ );
269
+ const data = parseResult(result);
270
+ expect(data.status).toBe('link_inserted');
271
+ expect(data.rollback.previous_value).toContain('stratégie digitale');
272
+
273
+ expect(api.calls).toHaveLength(1);
274
+ expect(api.calls[0].method).toBe('POST');
275
+ expect(JSON.parse(api.calls[0].body)).toEqual({ exact_text: 'stratégie digitale', link_url: 'https://example.com/cible' });
276
+
277
+ const entry = getAuditLogs().find(l => l.tool === 'elementor_insert_link');
278
+ expect(entry.action).toBe('insert_link');
279
+ expect(entry.target).toBe(42);
280
+ expect(entry.status).toBe('success');
281
+ });
282
+
283
+ it('elementor_insert_link validates inputs before any API call', async () => {
284
+ const api = mockApiRouted([]);
285
+ await expect(insertTool().handler({ post_id: 'x', exact_text: 'ok', link_url: 'https://a.b' }, api)).rejects.toThrow('post_id');
286
+ await expect(insertTool().handler({ post_id: 1, exact_text: ' ', link_url: 'https://a.b' }, api)).rejects.toThrow('exact_text');
287
+ await expect(insertTool().handler({ post_id: 1, exact_text: 'x'.repeat(501), link_url: 'https://a.b' }, api)).rejects.toThrow('exact_text');
288
+ await expect(insertTool().handler({ post_id: 1, exact_text: 'ok', link_url: 'javascript:alert(1)' }, api)).rejects.toThrow('link_url');
289
+ expect(api.calls).toHaveLength(0);
290
+ });
291
+
292
+ it('elementor_insert_link maps rest_no_route to a Companion install hint', async () => {
293
+ const api = mockApiRouted([]); // no companion route registered
294
+ await expect(
295
+ insertTool().handler({ post_id: 42, exact_text: 'ok', link_url: 'https://a.b' }, api)
296
+ ).rejects.toThrow(/MCP Companion plugin/);
297
+ });
298
+
299
+ it('elementor_insert_link propagates companion business errors (ambiguous, not built with Elementor…)', async () => {
300
+ const api = mockApiRouted([
301
+ { basePath: COMPANION, match: /^\/elementor\/insert-link\/42$/, error: 'WP API 409: \n{"code":"ambiguous_text","message":"exact_text appears 3 times"}' },
302
+ ]);
303
+ await expect(
304
+ insertTool().handler({ post_id: 42, exact_text: 'ok', link_url: 'https://a.b' }, api)
305
+ ).rejects.toThrow(/ambiguous_text/);
306
+ });
307
+
308
+ it('elementor_update_widget POSTs the rollback payload and audits', async () => {
309
+ const api = mockApiRouted([
310
+ { basePath: COMPANION, match: /^\/elementor\/update-widget\/42$/, data: { post_id: 42, status: 'widget_updated', widget_id: 'a1b2c3d', setting_key: 'editor', previous_value: '<p>avec lien</p>' } },
311
+ ]);
312
+ const result = await updateTool().handler(
313
+ { post_id: 42, widget_id: 'a1b2c3d', setting_key: 'editor', value: '<p>votre stratégie digitale mérite mieux</p>' },
314
+ api
315
+ );
316
+ const data = parseResult(result);
317
+ expect(data.status).toBe('widget_updated');
318
+ expect(api.calls[0].method).toBe('POST');
319
+ expect(JSON.parse(api.calls[0].body).setting_key).toBe('editor');
320
+
321
+ const entry = getAuditLogs().find(l => l.tool === 'elementor_update_widget');
322
+ expect(entry.action).toBe('update_widget');
323
+ expect(entry.status).toBe('success');
324
+ });
325
+
326
+ it('elementor_update_widget validates inputs and maps rest_no_route', async () => {
327
+ const api = mockApiRouted([]);
328
+ await expect(updateTool().handler({ post_id: 0, widget_id: 'a', setting_key: 'editor', value: '' }, api)).rejects.toThrow('post_id');
329
+ await expect(updateTool().handler({ post_id: 1, widget_id: '', setting_key: 'editor', value: '' }, api)).rejects.toThrow('widget_id');
330
+ await expect(updateTool().handler({ post_id: 1, widget_id: 'a', setting_key: '', value: '' }, api)).rejects.toThrow('setting_key');
331
+ await expect(updateTool().handler({ post_id: 1, widget_id: 'a', setting_key: 'editor', value: 42 }, api)).rejects.toThrow('value');
332
+ expect(api.calls).toHaveLength(0);
333
+
334
+ await expect(
335
+ updateTool().handler({ post_id: 1, widget_id: 'a', setting_key: 'editor', value: '' }, api)
336
+ ).rejects.toThrow(/MCP Companion plugin/);
337
+ });
338
+
339
+ it('elementor_get_page_data falls back to the companion data endpoint', async () => {
340
+ const api = mockApiRouted([
341
+ // elementor/v1 absent → the companion becomes the source of truth
342
+ { basePath: COMPANION, match: /^\/elementor\/data\/42$/, data: { post_id: 42, edit_mode: 'builder', elements: mockPageData.elements } },
343
+ ]);
344
+ const tool = elementorAdapter.getTools().find(t => t.name === 'elementor_get_page_data');
345
+ const result = await tool.handler({ post_id: 42 }, api);
346
+ const data = parseResult(result);
347
+ expect(data.elementor_status).toBe('active');
348
+ expect(data.widgets_used).toContain('text-editor');
349
+ });
350
+ });