@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.
- package/companion/mcp-diagnostics.php +556 -2
- package/index.js +4 -3
- package/package.json +1 -1
- package/src/plugins/adapters/elementor/elementorAdapter.js +126 -8
- package/src/tools/content.js +10 -3
- package/src/tools/seo.js +56 -4
- package/src/utils/contentCompressor.js +6 -1
- package/tests/unit/plugins/elementor/elementorAdapter.test.js +144 -0
- package/tests/unit/tools/contentCompressor.test.js +7 -0
- package/tests/unit/tools/posts.test.js +35 -0
- package/tests/unit/tools/seo.test.js +57 -4
|
@@ -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.
|
|
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.
|
|
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
|
+
"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
|
|
2
|
+
* Elementor Adapter.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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
|
-
*
|
|
10
|
-
*
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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',
|
package/src/tools/content.js
CHANGED
|
@@ -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=
|
|
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
|
-
|
|
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
|
-
|
|
195
|
-
|
|
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
|
-
|
|
198
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
|