@adsim/wordpress-mcp-server 5.3.1 → 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
@@ -140,6 +140,34 @@ function mcp_diagnostics_register_routes() {
140
140
  ),
141
141
  ) );
142
142
 
143
+ // ── SEO meta direct write (bypasses Yoast REST API block) ──
144
+ register_rest_route( $namespace, '/seo-meta/(?P<post_id>\d+)', array(
145
+ array(
146
+ 'methods' => 'GET',
147
+ 'callback' => 'mcp_diagnostics_seo_meta_get',
148
+ 'permission_callback' => 'mcp_diagnostics_schema_permission_check',
149
+ 'args' => array(
150
+ 'post_id' => array(
151
+ 'type' => 'integer',
152
+ 'required' => true,
153
+ 'sanitize_callback' => 'absint',
154
+ ),
155
+ ),
156
+ ),
157
+ array(
158
+ 'methods' => 'POST',
159
+ 'callback' => 'mcp_diagnostics_seo_meta_set',
160
+ 'permission_callback' => 'mcp_diagnostics_schema_permission_check',
161
+ 'args' => array(
162
+ 'post_id' => array(
163
+ 'type' => 'integer',
164
+ 'required' => true,
165
+ 'sanitize_callback' => 'absint',
166
+ ),
167
+ ),
168
+ ),
169
+ ) );
170
+
143
171
  // ── Polylang Free REST fallback ──
144
172
  register_rest_route( $namespace, '/polylang/languages', array(
145
173
  'methods' => 'GET',
@@ -244,6 +272,75 @@ function mcp_diagnostics_register_routes() {
244
272
  ),
245
273
  ),
246
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
+ ) );
247
344
  }
248
345
 
249
346
  /**
@@ -791,6 +888,143 @@ function mcp_schema_output_jsonld() {
791
888
  echo '<script type="application/ld+json">' . $schema . '</script>' . "\n";
792
889
  }
793
890
 
891
+ // ============================================================
892
+ // SEO Meta direct write — bypasses Yoast REST API block
893
+ // ============================================================
894
+
895
+ /**
896
+ * Allowed SEO meta keys grouped by plugin.
897
+ * Only these keys can be written via the direct endpoint.
898
+ */
899
+ function mcp_diagnostics_seo_allowed_keys() {
900
+ return array(
901
+ // Yoast SEO
902
+ '_yoast_wpseo_focuskw',
903
+ '_yoast_wpseo_title',
904
+ '_yoast_wpseo_metadesc',
905
+ '_yoast_wpseo_canonical',
906
+ '_yoast_wpseo_meta-robots-noindex',
907
+ '_yoast_wpseo_meta-robots-nofollow',
908
+ // RankMath
909
+ 'rank_math_title',
910
+ 'rank_math_description',
911
+ 'rank_math_focus_keyword',
912
+ 'rank_math_canonical_url',
913
+ // SEOPress
914
+ '_seopress_titles_title',
915
+ '_seopress_titles_desc',
916
+ '_seopress_analysis_target_kw',
917
+ '_seopress_robots_canonical',
918
+ '_seopress_robots_index',
919
+ '_seopress_robots_follow',
920
+ // All in One SEO
921
+ '_aioseo_title',
922
+ '_aioseo_description',
923
+ '_aioseo_keywords',
924
+ '_aioseo_canonical_url',
925
+ '_aioseo_noindex',
926
+ '_aioseo_nofollow',
927
+ );
928
+ }
929
+
930
+ /**
931
+ * GET /mcp-diagnostics/v1/seo-meta/{post_id}
932
+ * Read SEO meta fields directly from post_meta.
933
+ */
934
+ function mcp_diagnostics_seo_meta_get( $request ) {
935
+ $post_id = $request->get_param( 'post_id' );
936
+ $post = get_post( $post_id );
937
+
938
+ if ( ! $post ) {
939
+ return new WP_Error( 'not_found', __( 'Post not found.' ), array( 'status' => 404 ) );
940
+ }
941
+
942
+ $allowed = mcp_diagnostics_seo_allowed_keys();
943
+ $meta = array();
944
+
945
+ foreach ( $allowed as $key ) {
946
+ $val = get_post_meta( $post_id, $key, true );
947
+ if ( $val !== '' && $val !== false ) {
948
+ $meta[ $key ] = $val;
949
+ }
950
+ }
951
+
952
+ return new WP_REST_Response( array(
953
+ 'post_id' => $post_id,
954
+ 'meta' => $meta,
955
+ 'source' => 'direct_post_meta',
956
+ ), 200 );
957
+ }
958
+
959
+ /**
960
+ * POST /mcp-diagnostics/v1/seo-meta/{post_id}
961
+ * Write SEO meta fields directly via update_post_meta().
962
+ * Bypasses Yoast's REST API write block.
963
+ *
964
+ * Accepts JSON body: { "meta": { "_yoast_wpseo_focuskw": "value", ... } }
965
+ */
966
+ function mcp_diagnostics_seo_meta_set( $request ) {
967
+ if ( mcp_diagnostics_is_read_only() ) {
968
+ return new WP_Error( 'read_only', __( 'Site is in read-only mode.' ), array( 'status' => 403 ) );
969
+ }
970
+
971
+ $post_id = $request->get_param( 'post_id' );
972
+ $post = get_post( $post_id );
973
+
974
+ if ( ! $post ) {
975
+ return new WP_Error( 'not_found', __( 'Post not found.' ), array( 'status' => 404 ) );
976
+ }
977
+
978
+ $body = $request->get_json_params();
979
+ $meta = isset( $body['meta'] ) ? $body['meta'] : array();
980
+
981
+ if ( empty( $meta ) || ! is_array( $meta ) ) {
982
+ return new WP_Error( 'invalid_body', __( 'Request body must contain a "meta" object with key-value pairs.' ), array( 'status' => 400 ) );
983
+ }
984
+
985
+ $allowed = mcp_diagnostics_seo_allowed_keys();
986
+ $written = array();
987
+ $rejected = array();
988
+
989
+ foreach ( $meta as $key => $value ) {
990
+ if ( ! in_array( $key, $allowed, true ) ) {
991
+ $rejected[] = $key;
992
+ continue;
993
+ }
994
+
995
+ // Sanitize: URLs get esc_url_raw, everything else gets sanitize_text_field
996
+ if ( strpos( $key, 'canonical' ) !== false ) {
997
+ $value = esc_url_raw( $value );
998
+ } else {
999
+ $value = sanitize_text_field( $value );
1000
+ }
1001
+
1002
+ update_post_meta( $post_id, $key, $value );
1003
+ $written[] = $key;
1004
+ }
1005
+
1006
+ // Verify the writes
1007
+ $verified = array();
1008
+ foreach ( $written as $key ) {
1009
+ $actual = get_post_meta( $post_id, $key, true );
1010
+ $verified[ $key ] = $actual;
1011
+ }
1012
+
1013
+ $response = array(
1014
+ 'post_id' => $post_id,
1015
+ 'written' => $written,
1016
+ 'verified' => $verified,
1017
+ 'source' => 'direct_update_post_meta',
1018
+ );
1019
+
1020
+ if ( ! empty( $rejected ) ) {
1021
+ $response['rejected'] = $rejected;
1022
+ $response['warning'] = 'Some keys were rejected (not in allowed SEO meta keys list).';
1023
+ }
1024
+
1025
+ return new WP_REST_Response( $response, 200 );
1026
+ }
1027
+
794
1028
  // ============================================================
795
1029
  // Polylang Free REST fallback endpoints
796
1030
  // ============================================================
@@ -1182,3 +1416,323 @@ function mcp_diagnostics_wc_abandoned_carts( $request ) {
1182
1416
  // No source available
1183
1417
  return new WP_REST_Response( array( 'available' => false ), 200 );
1184
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.3.1",
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',
@@ -7,7 +7,7 @@ import { rt } from '../shared/context.js';
7
7
 
8
8
  export const definitions = [
9
9
  { name: 'wp_list_posts', _category: 'content', description: 'Use to browse/filter posts by status, category, tag, author, or search. Read-only. Hint: use mode=\'summary\' for listings, \'ids_only\' for batch ops.', inputSchema: { type: 'object', properties: { per_page: { type: 'number', default: 10 }, page: { type: 'number', default: 1 }, status: { type: 'string', default: 'publish' }, orderby: { type: 'string', default: 'date' }, order: { type: 'string', default: 'desc' }, categories: { type: 'string' }, tags: { type: 'string' }, search: { type: 'string' }, author: { type: 'number' }, mode: { type: 'string', default: 'full', description: 'full=all fields, summary=id/title/slug/date/status/link only, ids_only=flat ID array' } }}},
10
- { name: 'wp_get_post', _category: 'content', description: 'Use to read a single post with full content and meta. Read-only. Hint: use content_format=\'links_only\' for link audits (~800 chars vs 187k), \'text\' for rewrites, fields=[\'id\',\'title\',\'meta\'] for SEO-only.', inputSchema: { type: 'object', properties: { id: { type: 'number' }, fields: { type: 'array', items: { type: 'string' }, description: 'Return only specified fields (id,title,content,excerpt,slug,status,date,modified,categories,tags,author,featured_media,meta,link). Omit for all.' }, content_format: { type: 'string', default: 'html', description: 'html=raw HTML (truncated at WP_MAX_CONTENT_CHARS), text=plain text, links_only=extract internal links only' } }, required: ['id'] }},
10
+ { name: 'wp_get_post', _category: 'content', description: 'Use to read a single post with full content and meta. Read-only. Hint: use content_format=\'links_only\' for link audits (~800 chars vs 187k), \'text\' for rewrites, fields=[\'id\',\'title\',\'meta\'] for SEO-only.', inputSchema: { type: 'object', properties: { id: { type: 'number' }, fields: { type: 'array', items: { type: 'string' }, description: 'Return only specified fields (id,title,content,excerpt,slug,status,date,modified,categories,tags,author,featured_media,meta,link). Omit for all.' }, content_format: { type: 'string', enum: ['html', 'text', 'links_only', 'raw'], default: 'html', description: 'html=rendered HTML (truncated at WP_MAX_CONTENT_CHARS), text=plain text, links_only=extract internal links only, raw=Gutenberg source with block comments via ?context=edit (NEVER truncated — required before any read-modify-write of content)' } }, required: ['id'] }},
11
11
  { name: 'wp_create_post', _category: 'content', description: 'Use to create a post (defaults to draft). Accepts title, HTML content, categories, tags, featured_media, meta. Write — blocked by WP_READ_ONLY.', inputSchema: { type: 'object', properties: { title: { type: 'string' }, content: { type: 'string' }, status: { type: 'string', default: 'draft' }, excerpt: { type: 'string' }, categories: { type: 'array', items: { type: 'number' } }, tags: { type: 'array', items: { type: 'number' } }, slug: { type: 'string' }, featured_media: { type: 'number' }, meta: { type: 'object' }, author: { type: 'number' } }, required: ['title', 'content'] }},
12
12
  { name: 'wp_update_post', _category: 'content', description: 'Use to modify any post field — only provided fields change. Write — blocked by WP_READ_ONLY.', inputSchema: { type: 'object', properties: { id: { type: 'number' }, title: { type: 'string' }, content: { type: 'string' }, status: { type: 'string' }, excerpt: { type: 'string' }, categories: { type: 'array', items: { type: 'number' } }, tags: { type: 'array', items: { type: 'number' } }, slug: { type: 'string' }, featured_media: { type: 'number' }, meta: { type: 'object' }, author: { type: 'number' } }, required: ['id'] }},
13
13
  { name: 'wp_delete_post', _category: 'content', description: 'Use to trash or permanently delete a post. Write — blocked by WP_READ_ONLY, WP_DISABLE_DELETE.', inputSchema: { type: 'object', properties: { id: { type: 'number' }, force: { type: 'boolean', default: false }, confirmation_token: { type: 'string', description: 'Confirmation token returned by the first call when WP_CONFIRM_DESTRUCTIVE=true' } }, required: ['id'] }},
@@ -53,12 +53,19 @@ handlers['wp_get_post'] = async (args) => {
53
53
  const t0 = Date.now();
54
54
  let result;
55
55
  const { wpApiCall, getActiveAuth, auditLog, name, summarizePost, applyContentFormat } = rt;
56
- validateInput(args, { id: { type: 'number', required: true, min: 1 }, fields: { type: 'array' }, content_format: { type: 'string', enum: ['html', 'text', 'links_only'] } });
56
+ validateInput(args, { id: { type: 'number', required: true, min: 1 }, fields: { type: 'array' }, content_format: { type: 'string', enum: ['html', 'text', 'links_only', 'raw'] } });
57
57
  const { content_format = 'html', fields: requestedFields } = args;
58
- const p = await wpApiCall(`/posts/${args.id}`);
58
+ // raw = source Gutenberg (block comments) : REST ?context=edit expose
59
+ // content.raw / title.raw (droits d'édition requis — déjà le cas ici).
60
+ const p = await wpApiCall(`/posts/${args.id}${content_format === 'raw' ? '?context=edit' : ''}`);
59
61
  let postData = { id: p.id, title: p.title.rendered, content: p.content.rendered, excerpt: p.excerpt.rendered, status: p.status, date: p.date, modified: p.modified, link: p.link, slug: p.slug, categories: p.categories, tags: p.tags, author: p.author, featured_media: p.featured_media, comment_status: p.comment_status, meta: p.meta || {} };
60
62
  if (p.acf && Object.keys(p.acf).length > 0) { postData.acf_fields = p.acf; }
61
63
  else { postData.acf_fields = {}; postData.acf_hint = 'ACF returned empty. Verify Show in REST API is enabled in each Field Group settings in WordPress Admin.'; }
64
+ if (content_format === 'raw') {
65
+ postData.content = p.content.raw ?? p.content.rendered;
66
+ postData.title = p.title.raw ?? p.title.rendered;
67
+ postData.excerpt = p.excerpt.raw ?? p.excerpt.rendered;
68
+ }
62
69
  const { url: siteUrl } = getActiveAuth();
63
70
  postData = applyContentFormat(postData, content_format, siteUrl);
64
71
  if (requestedFields && requestedFields.length > 0) postData = summarizePost(postData, requestedFields);
package/src/tools/seo.js CHANGED
@@ -191,11 +191,63 @@ handlers['wp_update_seo_meta'] = async (args) => {
191
191
 
192
192
  if (updated.length === 0) throw new Error('No SEO fields provided. Specify at least one of: title, description, focus_keyword, canonical_url, robots_noindex, robots_nofollow.');
193
193
 
194
- const writeEp = post_type === 'page' ? `/pages/${id}` : `/posts/${id}`;
195
- await wpApiCall(writeEp, { method: 'POST', body: JSON.stringify({ meta: metaUpdate }) });
194
+ // ── Strategy 1: Try MCP Companion direct write (uses update_post_meta, bypasses Yoast block) ──
195
+ let writeMethod = 'rest_api';
196
+ let verified = true;
197
+ const failedFields = [];
196
198
 
197
- result = json({ success: true, message: `SEO meta updated for ${post_type} ${id}`, plugin, fields_updated: updated, meta_written: metaUpdate });
198
- auditLog({ tool: name, target: id, target_type: post_type, action: 'update_seo', status: 'success', latency_ms: Date.now() - t0, params: { plugin, fields: updated } });
199
+ try {
200
+ const companionResult = await wpApiCall(`/${id}`, {
201
+ method: 'POST',
202
+ basePath: '/wp-json/mcp-diagnostics/v1/seo-meta',
203
+ body: JSON.stringify({ meta: metaUpdate }),
204
+ });
205
+ // Companion returns { written: [...], verified: { key: value } }
206
+ if (companionResult?.written?.length > 0) {
207
+ writeMethod = 'companion_direct';
208
+ // Verify from companion response
209
+ for (const [metaKey, expectedVal] of Object.entries(metaUpdate)) {
210
+ const actual = companionResult.verified?.[metaKey];
211
+ if (actual === undefined || actual === null || String(actual) !== String(expectedVal)) {
212
+ failedFields.push({ key: metaKey, expected: expectedVal, actual: actual ?? null });
213
+ }
214
+ }
215
+ if (failedFields.length > 0) verified = false;
216
+ }
217
+ } catch {
218
+ // Companion not installed — fall back to REST API
219
+ writeMethod = 'rest_api';
220
+ }
221
+
222
+ // ── Strategy 2: Standard REST API (fallback when companion not available) ──
223
+ if (writeMethod === 'rest_api') {
224
+ const writeEp = post_type === 'page' ? `/pages/${id}` : `/posts/${id}`;
225
+ await wpApiCall(writeEp, { method: 'POST', body: JSON.stringify({ meta: metaUpdate }) });
226
+
227
+ // Verify write: read back meta to detect silent failures (Yoast issue)
228
+ try {
229
+ const verify = await wpApiCall(readEp);
230
+ const verifyMeta = verify.meta || {};
231
+ for (const [metaKey, expectedVal] of Object.entries(metaUpdate)) {
232
+ const actual = verifyMeta[metaKey];
233
+ if (actual === undefined || actual === null || String(actual) !== String(expectedVal)) {
234
+ failedFields.push({ key: metaKey, expected: expectedVal, actual: actual ?? null });
235
+ }
236
+ }
237
+ if (failedFields.length > 0) verified = false;
238
+ } catch { verified = false; }
239
+ }
240
+
241
+ const response = { success: verified, message: `SEO meta updated for ${post_type} ${id}`, plugin, fields_updated: updated, meta_written: metaUpdate, verified, write_method: writeMethod };
242
+ if (!verified && failedFields.length > 0) {
243
+ response.failed_fields = failedFields;
244
+ response.warning = `Silent write failure detected: ${failedFields.length} field(s) were not saved by WordPress. This is a known issue with Yoast SEO which blocks REST API writes to its proprietary meta fields.`;
245
+ response.fix = 'Install the MCP Companion plugin (companion/mcp-diagnostics.php) as a must-use plugin to enable direct update_post_meta() writes that bypass Yoast restrictions. Copy it to wp-content/mu-plugins/mcp-diagnostics.php and retry.';
246
+ auditLog({ tool: name, target: id, target_type: post_type, action: 'update_seo', status: 'silent_failure', latency_ms: Date.now() - t0, params: { plugin, fields: updated, failed: failedFields.map(f => f.key), write_method: writeMethod } });
247
+ } else {
248
+ auditLog({ tool: name, target: id, target_type: post_type, action: 'update_seo', status: 'success', latency_ms: Date.now() - t0, params: { plugin, fields: updated, write_method: writeMethod } });
249
+ }
250
+ result = json(response);
199
251
  return result;
200
252
  };
201
253
  handlers['wp_audit_seo'] = async (args) => {
@@ -88,7 +88,7 @@ export function summarizePost(post, fields) {
88
88
  /**
89
89
  * Apply content_format transformation to a post object.
90
90
  * @param {object} post Post with .content field
91
- * @param {string} contentFormat 'html' | 'text' | 'links_only'
91
+ * @param {string} contentFormat 'html' | 'text' | 'links_only' | 'raw'
92
92
  * @param {string} siteUrl Site base URL
93
93
  * @param {number} maxChars Truncation limit (0 = unlimited)
94
94
  * @returns {object}
@@ -106,6 +106,11 @@ export function applyContentFormat(post, contentFormat, siteUrl, maxChars = DEFA
106
106
  processed.internal_links = extractLinksOnly(post.content, siteUrl);
107
107
  processed._content_format = 'links_only';
108
108
  break;
109
+ case 'raw':
110
+ // Source Gutenberg destinée à être réécrite telle quelle :
111
+ // AUCUNE troncature (un raw tronqué corromprait le post à l'update).
112
+ processed._content_format = 'raw';
113
+ break;
109
114
  case 'html':
110
115
  default:
111
116
  processed.content = truncateContent(post.content, maxChars);
@@ -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
+ });
@@ -143,6 +143,13 @@ describe('contentCompressor — summarizePost', () => {
143
143
  });
144
144
 
145
145
  describe('contentCompressor — applyContentFormat', () => {
146
+ it('raw : passthrough intégral, jamais tronqué', () => {
147
+ const long = '<!-- wp:paragraph -->' + 'x'.repeat(50000) + '<!-- /wp:paragraph -->';
148
+ const result = applyContentFormat({ content: long }, 'raw', 'https://s.tld', 100);
149
+ expect(result.content).toBe(long);
150
+ expect(result._content_format).toBe('raw');
151
+ });
152
+
146
153
  const siteUrl = 'https://mysite.example.com';
147
154
 
148
155
  it('html mode — truncates long content', () => {
@@ -52,6 +52,41 @@ describe('wp_list_posts', () => {
52
52
  // ────────────────────────────────────────────────────────────
53
53
 
54
54
  describe('wp_get_post', () => {
55
+ describe('content_format=raw', () => {
56
+ const RAW = '<!-- wp:paragraph --><p>Source brute</p><!-- /wp:paragraph -->';
57
+ const rawPost = {
58
+ id: 1,
59
+ title: { rendered: 'Rendu', raw: 'Brut' },
60
+ content: { rendered: '<p>Source brute</p>', raw: RAW },
61
+ excerpt: { rendered: 'ex', raw: 'ex brut' },
62
+ status: 'publish', date: '2024-01-01', modified: '2024-01-01',
63
+ link: 'https://test.example.com/post-1', slug: 'post-1',
64
+ categories: [], tags: [], author: 1, featured_media: 0,
65
+ comment_status: 'open', meta: {},
66
+ };
67
+
68
+ it('appelle la REST API avec ?context=edit', async () => {
69
+ fetch.mockResolvedValue(mockSuccess(rawPost));
70
+ await call('wp_get_post', { id: 1, content_format: 'raw' });
71
+ expect(String(fetch.mock.calls[0][0])).toContain('/posts/1?context=edit');
72
+ });
73
+
74
+ it('retourne content.raw et title.raw sans troncature', async () => {
75
+ fetch.mockResolvedValue(mockSuccess(rawPost));
76
+ const data = parseResult(await call('wp_get_post', { id: 1, content_format: 'raw' }));
77
+ expect(data.content).toBe(RAW);
78
+ expect(data.title).toBe('Brut');
79
+ expect(data._content_format).toBe('raw');
80
+ });
81
+
82
+ it('sans raw, pas de context=edit et contenu rendu', async () => {
83
+ fetch.mockResolvedValue(mockSuccess(rawPost));
84
+ const data = parseResult(await call('wp_get_post', { id: 1 }));
85
+ expect(String(fetch.mock.calls[0][0])).not.toContain('context=edit');
86
+ expect(data.content).toBe('<p>Source brute</p>');
87
+ });
88
+ });
89
+
55
90
  let consoleSpy;
56
91
  beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
57
92
  afterEach(() => { consoleSpy.mockRestore(); });
@@ -105,21 +105,70 @@ describe('wp_get_seo_meta', () => {
105
105
  // =========================================================================
106
106
 
107
107
  describe('wp_update_seo_meta', () => {
108
- it('SUCCESS detects Yoast and updates title + description', async () => {
108
+ it('SUCCESS via companion uses direct update_post_meta', async () => {
109
109
  // First fetch: read current post to detect plugin
110
110
  mockSuccess(yoastPost);
111
- // Second fetch: write the meta update
112
- mockSuccess({});
111
+ // Second fetch: companion endpoint succeeds with verified values
112
+ mockSuccess({
113
+ written: ['_yoast_wpseo_title', '_yoast_wpseo_metadesc'],
114
+ verified: { _yoast_wpseo_title: 'New SEO Title', _yoast_wpseo_metadesc: 'New desc' },
115
+ source: 'direct_update_post_meta',
116
+ });
113
117
 
114
118
  const res = await call('wp_update_seo_meta', { id: 1, title: 'New SEO Title', description: 'New desc' });
115
119
  const data = parseResult(res);
116
120
 
117
121
  expect(data.success).toBe(true);
122
+ expect(data.verified).toBe(true);
123
+ expect(data.write_method).toBe('companion_direct');
118
124
  expect(data.plugin).toBe('yoast');
119
125
  expect(data.fields_updated).toContain('title');
120
126
  expect(data.fields_updated).toContain('description');
121
127
  });
122
128
 
129
+ it('SUCCESS via REST fallback — companion not installed', async () => {
130
+ // First fetch: read current post to detect plugin
131
+ mockSuccess(yoastPost);
132
+ // Second fetch: companion endpoint → 404 (not installed)
133
+ mockError(404, '{"code":"rest_no_route"}');
134
+ // Third fetch: standard REST write
135
+ mockSuccess({});
136
+ // Fourth fetch: verification read — meta reflects the written values
137
+ mockSuccess({ ...yoastPost, meta: { ...yoastPost.meta, _yoast_wpseo_title: 'New SEO Title', _yoast_wpseo_metadesc: 'New desc' } });
138
+
139
+ const res = await call('wp_update_seo_meta', { id: 1, title: 'New SEO Title', description: 'New desc' });
140
+ const data = parseResult(res);
141
+
142
+ expect(data.success).toBe(true);
143
+ expect(data.verified).toBe(true);
144
+ expect(data.write_method).toBe('rest_api');
145
+ expect(data.plugin).toBe('yoast');
146
+ });
147
+
148
+ it('SILENT FAILURE — companion unavailable and REST write blocked by Yoast', async () => {
149
+ // First fetch: read current post to detect plugin
150
+ mockSuccess(yoastPost);
151
+ // Second fetch: companion → 404
152
+ mockError(404, '{"code":"rest_no_route"}');
153
+ // Third fetch: REST write — WordPress returns success but does not save
154
+ mockSuccess({});
155
+ // Fourth fetch: verification read — meta unchanged (silent failure)
156
+ mockSuccess(yoastPost);
157
+
158
+ const res = await call('wp_update_seo_meta', { id: 1, focus_keyword: 'new keyword' });
159
+ const data = parseResult(res);
160
+
161
+ expect(data.success).toBe(false);
162
+ expect(data.verified).toBe(false);
163
+ expect(data.write_method).toBe('rest_api');
164
+ expect(data.failed_fields).toBeDefined();
165
+ expect(data.failed_fields.length).toBeGreaterThan(0);
166
+ expect(data.failed_fields[0].key).toBe('_yoast_wpseo_focuskw');
167
+ expect(data.failed_fields[0].expected).toBe('new keyword');
168
+ expect(data.warning).toContain('Silent write failure');
169
+ expect(data.fix).toContain('mcp-diagnostics.php');
170
+ });
171
+
123
172
  it('GOVERNANCE — blocked by WP_READ_ONLY', async () => {
124
173
  const original = process.env.WP_READ_ONLY;
125
174
  process.env.WP_READ_ONLY = 'true';
@@ -152,7 +201,11 @@ describe('wp_update_seo_meta', () => {
152
201
 
153
202
  it('AUDIT — logs update_seo action on success', async () => {
154
203
  mockSuccess(yoastPost);
155
- mockSuccess({});
204
+ // Companion succeeds
205
+ mockSuccess({
206
+ written: ['_yoast_wpseo_title'],
207
+ verified: { _yoast_wpseo_title: 'Audit Test' },
208
+ });
156
209
 
157
210
  await call('wp_update_seo_meta', { id: 1, title: 'Audit Test' });
158
211