@adsim/wordpress-mcp-server 5.1.0 → 5.4.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/README.md +17 -1
- package/companion/mcp-diagnostics.php +165 -0
- package/package.json +1 -1
- package/src/plugins/adapters/acf/acfAdapter.js +55 -3
- package/src/tools/content.js +55 -6
- package/src/tools/index.js +1 -1
- package/src/tools/seo.js +56 -4
- package/src/utils/contentCompressor.js +6 -1
- package/tests/unit/plugins/acf/acfAdapter.test.js +43 -5
- package/tests/unit/tools/contentCompressor.test.js +7 -0
- package/tests/unit/tools/dynamicFiltering.test.js +5 -5
- package/tests/unit/tools/postMeta.test.js +105 -0
- package/tests/unit/tools/posts.test.js +35 -0
- package/tests/unit/tools/seo.test.js +57 -4
- package/tests/unit/tools/site.test.js +1 -1
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
The enterprise governance layer for Claude-to-WordPress integrations — secure, auditable, and multi-site.
|
|
12
12
|
|
|
13
|
-
**v5.1
|
|
13
|
+
**v5.3.1 Enterprise** · 176 tools · ~1109 Vitest tests · GitHub Actions CI
|
|
14
14
|
|
|
15
15
|
---
|
|
16
16
|
|
|
@@ -1300,6 +1300,22 @@ npx @modelcontextprotocol/inspector node index.js
|
|
|
1300
1300
|
|
|
1301
1301
|
## Changelog
|
|
1302
1302
|
|
|
1303
|
+
### v5.3.1 (2026-03-11) — ACF REST Integration Fix
|
|
1304
|
+
|
|
1305
|
+
- Fix: `wp_get_post` and `wp_get_page` now include `acf_fields` from WP REST `acf` field
|
|
1306
|
+
- Fix: `wp_get_post_meta` includes `acf_fields` alongside meta via `?_fields=meta,acf`
|
|
1307
|
+
- Fix: `acf_get_fields` second fallback via `wp/v2?_fields=acf` when acf/v3 returns empty
|
|
1308
|
+
- Hint displayed when ACF data is empty (Show in REST API diagnostic)
|
|
1309
|
+
- 176 tools · ~1109 Vitest tests
|
|
1310
|
+
|
|
1311
|
+
### v5.3.0 (2026-03-11) — ACF Fallback + Post Meta
|
|
1312
|
+
|
|
1313
|
+
- `wp_get_post_meta`: generic post meta reader — ACF, Elementor, Yoast, any custom meta
|
|
1314
|
+
- ACF adapter: WP Core meta fallback when acf/v3 returns 404 (ACF free support)
|
|
1315
|
+
- ACF adapter: diagnostic warning with causes when fields are empty
|
|
1316
|
+
- Auto-parse JSON meta values (_elementor_data, etc.)
|
|
1317
|
+
- 176 tools · ~1109 Vitest tests
|
|
1318
|
+
|
|
1303
1319
|
### v5.1.0 (2026-03-11) — Workflow Orchestrator
|
|
1304
1320
|
|
|
1305
1321
|
- `wp_run_workflow`: execute named or custom tool sequences in a single call
|
|
@@ -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',
|
|
@@ -791,6 +819,143 @@ function mcp_schema_output_jsonld() {
|
|
|
791
819
|
echo '<script type="application/ld+json">' . $schema . '</script>' . "\n";
|
|
792
820
|
}
|
|
793
821
|
|
|
822
|
+
// ============================================================
|
|
823
|
+
// SEO Meta direct write — bypasses Yoast REST API block
|
|
824
|
+
// ============================================================
|
|
825
|
+
|
|
826
|
+
/**
|
|
827
|
+
* Allowed SEO meta keys grouped by plugin.
|
|
828
|
+
* Only these keys can be written via the direct endpoint.
|
|
829
|
+
*/
|
|
830
|
+
function mcp_diagnostics_seo_allowed_keys() {
|
|
831
|
+
return array(
|
|
832
|
+
// Yoast SEO
|
|
833
|
+
'_yoast_wpseo_focuskw',
|
|
834
|
+
'_yoast_wpseo_title',
|
|
835
|
+
'_yoast_wpseo_metadesc',
|
|
836
|
+
'_yoast_wpseo_canonical',
|
|
837
|
+
'_yoast_wpseo_meta-robots-noindex',
|
|
838
|
+
'_yoast_wpseo_meta-robots-nofollow',
|
|
839
|
+
// RankMath
|
|
840
|
+
'rank_math_title',
|
|
841
|
+
'rank_math_description',
|
|
842
|
+
'rank_math_focus_keyword',
|
|
843
|
+
'rank_math_canonical_url',
|
|
844
|
+
// SEOPress
|
|
845
|
+
'_seopress_titles_title',
|
|
846
|
+
'_seopress_titles_desc',
|
|
847
|
+
'_seopress_analysis_target_kw',
|
|
848
|
+
'_seopress_robots_canonical',
|
|
849
|
+
'_seopress_robots_index',
|
|
850
|
+
'_seopress_robots_follow',
|
|
851
|
+
// All in One SEO
|
|
852
|
+
'_aioseo_title',
|
|
853
|
+
'_aioseo_description',
|
|
854
|
+
'_aioseo_keywords',
|
|
855
|
+
'_aioseo_canonical_url',
|
|
856
|
+
'_aioseo_noindex',
|
|
857
|
+
'_aioseo_nofollow',
|
|
858
|
+
);
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* GET /mcp-diagnostics/v1/seo-meta/{post_id}
|
|
863
|
+
* Read SEO meta fields directly from post_meta.
|
|
864
|
+
*/
|
|
865
|
+
function mcp_diagnostics_seo_meta_get( $request ) {
|
|
866
|
+
$post_id = $request->get_param( 'post_id' );
|
|
867
|
+
$post = get_post( $post_id );
|
|
868
|
+
|
|
869
|
+
if ( ! $post ) {
|
|
870
|
+
return new WP_Error( 'not_found', __( 'Post not found.' ), array( 'status' => 404 ) );
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
$allowed = mcp_diagnostics_seo_allowed_keys();
|
|
874
|
+
$meta = array();
|
|
875
|
+
|
|
876
|
+
foreach ( $allowed as $key ) {
|
|
877
|
+
$val = get_post_meta( $post_id, $key, true );
|
|
878
|
+
if ( $val !== '' && $val !== false ) {
|
|
879
|
+
$meta[ $key ] = $val;
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
return new WP_REST_Response( array(
|
|
884
|
+
'post_id' => $post_id,
|
|
885
|
+
'meta' => $meta,
|
|
886
|
+
'source' => 'direct_post_meta',
|
|
887
|
+
), 200 );
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/**
|
|
891
|
+
* POST /mcp-diagnostics/v1/seo-meta/{post_id}
|
|
892
|
+
* Write SEO meta fields directly via update_post_meta().
|
|
893
|
+
* Bypasses Yoast's REST API write block.
|
|
894
|
+
*
|
|
895
|
+
* Accepts JSON body: { "meta": { "_yoast_wpseo_focuskw": "value", ... } }
|
|
896
|
+
*/
|
|
897
|
+
function mcp_diagnostics_seo_meta_set( $request ) {
|
|
898
|
+
if ( mcp_diagnostics_is_read_only() ) {
|
|
899
|
+
return new WP_Error( 'read_only', __( 'Site is in read-only mode.' ), array( 'status' => 403 ) );
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
$post_id = $request->get_param( 'post_id' );
|
|
903
|
+
$post = get_post( $post_id );
|
|
904
|
+
|
|
905
|
+
if ( ! $post ) {
|
|
906
|
+
return new WP_Error( 'not_found', __( 'Post not found.' ), array( 'status' => 404 ) );
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
$body = $request->get_json_params();
|
|
910
|
+
$meta = isset( $body['meta'] ) ? $body['meta'] : array();
|
|
911
|
+
|
|
912
|
+
if ( empty( $meta ) || ! is_array( $meta ) ) {
|
|
913
|
+
return new WP_Error( 'invalid_body', __( 'Request body must contain a "meta" object with key-value pairs.' ), array( 'status' => 400 ) );
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
$allowed = mcp_diagnostics_seo_allowed_keys();
|
|
917
|
+
$written = array();
|
|
918
|
+
$rejected = array();
|
|
919
|
+
|
|
920
|
+
foreach ( $meta as $key => $value ) {
|
|
921
|
+
if ( ! in_array( $key, $allowed, true ) ) {
|
|
922
|
+
$rejected[] = $key;
|
|
923
|
+
continue;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// Sanitize: URLs get esc_url_raw, everything else gets sanitize_text_field
|
|
927
|
+
if ( strpos( $key, 'canonical' ) !== false ) {
|
|
928
|
+
$value = esc_url_raw( $value );
|
|
929
|
+
} else {
|
|
930
|
+
$value = sanitize_text_field( $value );
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
update_post_meta( $post_id, $key, $value );
|
|
934
|
+
$written[] = $key;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// Verify the writes
|
|
938
|
+
$verified = array();
|
|
939
|
+
foreach ( $written as $key ) {
|
|
940
|
+
$actual = get_post_meta( $post_id, $key, true );
|
|
941
|
+
$verified[ $key ] = $actual;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
$response = array(
|
|
945
|
+
'post_id' => $post_id,
|
|
946
|
+
'written' => $written,
|
|
947
|
+
'verified' => $verified,
|
|
948
|
+
'source' => 'direct_update_post_meta',
|
|
949
|
+
);
|
|
950
|
+
|
|
951
|
+
if ( ! empty( $rejected ) ) {
|
|
952
|
+
$response['rejected'] = $rejected;
|
|
953
|
+
$response['warning'] = 'Some keys were rejected (not in allowed SEO meta keys list).';
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
return new WP_REST_Response( $response, 200 );
|
|
957
|
+
}
|
|
958
|
+
|
|
794
959
|
// ============================================================
|
|
795
960
|
// Polylang Free REST fallback endpoints
|
|
796
961
|
// ============================================================
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adsim/wordpress-mcp-server",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.4.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",
|
|
@@ -33,9 +33,37 @@ async function handleGetFields(args, apiRequest) {
|
|
|
33
33
|
const t0 = Date.now();
|
|
34
34
|
const { id, post_type = 'posts', fields, mode = 'compact' } = args;
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
let acfData = {};
|
|
37
|
+
let source = 'acf_rest_api';
|
|
38
|
+
let fallbackMeta = null;
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const data = await apiRequest(`/${post_type}/${id}`, { basePath: '/wp-json/acf/v3' });
|
|
42
|
+
acfData = data?.acf ?? data ?? {};
|
|
43
|
+
} catch (e) {
|
|
44
|
+
// Fallback: read from WP Core meta if acf/v3 returns 404
|
|
45
|
+
if (e.message.includes('404') || e.message.includes('rest_no_route')) {
|
|
46
|
+
try {
|
|
47
|
+
const post = await apiRequest(`/${post_type}/${id}?_fields=meta`, { basePath: '/wp-json/wp/v2' });
|
|
48
|
+
acfData = post?.meta ?? {};
|
|
49
|
+
source = 'wp_core_meta';
|
|
50
|
+
fallbackMeta = acfData;
|
|
51
|
+
} catch { throw e; }
|
|
52
|
+
} else {
|
|
53
|
+
throw e;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
37
56
|
|
|
38
|
-
|
|
57
|
+
// Second fallback: try WP REST acf field if acf/v3 returned empty
|
|
58
|
+
if (Object.keys(acfData).length === 0 && source === 'acf_rest_api') {
|
|
59
|
+
try {
|
|
60
|
+
const post = await apiRequest(`/${post_type}/${id}?_fields=acf`, { basePath: '/wp-json/wp/v2' });
|
|
61
|
+
if (post?.acf && Object.keys(post.acf).length > 0) {
|
|
62
|
+
acfData = post.acf;
|
|
63
|
+
source = 'wp_rest_acf_field';
|
|
64
|
+
}
|
|
65
|
+
} catch { /* ignore — will show diagnostic below */ }
|
|
66
|
+
}
|
|
39
67
|
|
|
40
68
|
// Filter to requested keys
|
|
41
69
|
if (fields && fields.length > 0) {
|
|
@@ -46,8 +74,32 @@ async function handleGetFields(args, apiRequest) {
|
|
|
46
74
|
acfData = filtered;
|
|
47
75
|
}
|
|
48
76
|
|
|
77
|
+
// Diagnostic if empty
|
|
78
|
+
if (Object.keys(acfData).length === 0) {
|
|
79
|
+
const result = {
|
|
80
|
+
id, post_type, fields_count: 0, acf: {},
|
|
81
|
+
source,
|
|
82
|
+
warning: 'No ACF fields returned. Possible causes:',
|
|
83
|
+
causes: [
|
|
84
|
+
'1. Field group has Show in REST API = No → Enable in ACF Admin > Field Groups > Settings',
|
|
85
|
+
'2. ACF free version without REST support → Upgrade to ACF Pro or add acf_rest_api filter',
|
|
86
|
+
'3. Post type not in field group location rules',
|
|
87
|
+
'4. Post ID incorrect'
|
|
88
|
+
],
|
|
89
|
+
};
|
|
90
|
+
if (fallbackMeta !== null) result.fallback_meta = fallbackMeta;
|
|
91
|
+
auditLog({ tool: 'acf_get_fields', action: 'read_acf_fields', target: id, status: 'empty', latency_ms: Date.now() - t0 });
|
|
92
|
+
return json(result);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const responseData = { id, post_type, fields_count: Object.keys(acfData).length, acf: acfData };
|
|
96
|
+
if (source !== 'acf_rest_api') {
|
|
97
|
+
responseData.source = source;
|
|
98
|
+
responseData.warning = 'ACF REST API not available. Data read from WP Core meta. Enable Show in REST API in field group settings.';
|
|
99
|
+
}
|
|
100
|
+
|
|
49
101
|
const guarded = applyContextGuard(
|
|
50
|
-
|
|
102
|
+
responseData,
|
|
51
103
|
{ toolName: 'acf_get_fields', mode, maxChars: 30000 }
|
|
52
104
|
);
|
|
53
105
|
|
package/src/tools/content.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// src/tools/content.js — content tools (
|
|
1
|
+
// src/tools/content.js — content tools (13)
|
|
2
2
|
// Definitions + handlers (v5.0.0 refactor Step B+C)
|
|
3
3
|
|
|
4
4
|
import { json, strip, buildPaginationMeta, validateBlocks } from '../shared/utils.js';
|
|
@@ -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'] }},
|
|
@@ -19,7 +19,9 @@ export const definitions = [
|
|
|
19
19
|
{ name: 'wp_list_pages', _category: 'content', description: 'Use to browse pages with hierarchy and templates. Supports parent filter, menu_order sort. 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' }, parent: { type: 'number' }, orderby: { type: 'string', default: 'menu_order' }, order: { type: 'string', default: 'asc' }, search: { type: 'string' }, mode: { type: 'string', enum: ['full', 'summary', 'ids_only'], default: 'full', description: 'full=all fields, summary=key fields only, ids_only=flat ID array' } }}},
|
|
20
20
|
{ name: 'wp_get_page', _category: 'content', description: 'Use to read a single page with content and template. Read-only. Warning: Elementor pages can exceed 100k chars.', inputSchema: { type: 'object', properties: { id: { type: 'number' } }, required: ['id'] }},
|
|
21
21
|
{ name: 'wp_create_page', _category: 'content', description: 'Use to create a page (defaults to draft). Supports parent, template, menu_order. Write — blocked by WP_READ_ONLY.', inputSchema: { type: 'object', properties: { title: { type: 'string' }, content: { type: 'string' }, status: { type: 'string', default: 'draft' }, parent: { type: 'number', default: 0 }, template: { type: 'string' }, menu_order: { type: 'number', default: 0 }, excerpt: { type: 'string' }, slug: { type: 'string' }, featured_media: { type: 'number' }, meta: { type: 'object' } }, required: ['title', 'content'] }},
|
|
22
|
-
{ name: 'wp_update_page', _category: 'content', description: 'Use to modify any page field. Write — blocked by WP_READ_ONLY.', inputSchema: { type: 'object', properties: { id: { type: 'number' }, title: { type: 'string' }, content: { type: 'string' }, status: { type: 'string' }, parent: { type: 'number' }, template: { type: 'string' }, menu_order: { type: 'number' }, excerpt: { type: 'string' }, slug: { type: 'string' }, featured_media: { type: 'number' }, meta: { type: 'object' } }, required: ['id'] }}
|
|
22
|
+
{ name: 'wp_update_page', _category: 'content', description: 'Use to modify any page field. Write — blocked by WP_READ_ONLY.', inputSchema: { type: 'object', properties: { id: { type: 'number' }, title: { type: 'string' }, content: { type: 'string' }, status: { type: 'string' }, parent: { type: 'number' }, template: { type: 'string' }, menu_order: { type: 'number' }, excerpt: { type: 'string' }, slug: { type: 'string' }, featured_media: { type: 'number' }, meta: { type: 'object' } }, required: ['id'] }},
|
|
23
|
+
{ name: 'wp_get_post_meta', _category: 'content', description: 'Read post meta fields. Use for ACF data if acf/v3 unavailable, or for any custom meta (_elementor_data, _yoast_wpseo_*, etc.). Returns all meta if meta_key omitted.',
|
|
24
|
+
inputSchema: { type: 'object', properties: { post_id: { type: 'number' }, post_type: { type: 'string', default: 'posts', description: 'posts or pages' }, meta_key: { type: 'string', description: 'Specific key. Omit for all meta.' }, parse_json: { type: 'boolean', default: true, description: 'Auto-parse JSON strings (e.g. _elementor_data)' } }, required: ['post_id'] }}
|
|
23
25
|
];
|
|
24
26
|
|
|
25
27
|
export const handlers = {};
|
|
@@ -51,10 +53,19 @@ handlers['wp_get_post'] = async (args) => {
|
|
|
51
53
|
const t0 = Date.now();
|
|
52
54
|
let result;
|
|
53
55
|
const { wpApiCall, getActiveAuth, auditLog, name, summarizePost, applyContentFormat } = rt;
|
|
54
|
-
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'] } });
|
|
55
57
|
const { content_format = 'html', fields: requestedFields } = args;
|
|
56
|
-
|
|
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' : ''}`);
|
|
57
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 || {} };
|
|
62
|
+
if (p.acf && Object.keys(p.acf).length > 0) { postData.acf_fields = p.acf; }
|
|
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
|
+
}
|
|
58
69
|
const { url: siteUrl } = getActiveAuth();
|
|
59
70
|
postData = applyContentFormat(postData, content_format, siteUrl);
|
|
60
71
|
if (requestedFields && requestedFields.length > 0) postData = summarizePost(postData, requestedFields);
|
|
@@ -320,7 +331,10 @@ handlers['wp_get_page'] = async (args) => {
|
|
|
320
331
|
const { wpApiCall, auditLog, name } = rt;
|
|
321
332
|
validateInput(args, { id: { type: 'number', required: true, min: 1 } });
|
|
322
333
|
const pg = await wpApiCall(`/pages/${args.id}`);
|
|
323
|
-
|
|
334
|
+
const pageData = { id: pg.id, title: pg.title.rendered, content: pg.content.rendered, excerpt: pg.excerpt.rendered, status: pg.status, date: pg.date, modified: pg.modified, link: pg.link, slug: pg.slug, parent: pg.parent, menu_order: pg.menu_order, template: pg.template, author: pg.author, featured_media: pg.featured_media, meta: pg.meta || {} };
|
|
335
|
+
if (pg.acf && Object.keys(pg.acf).length > 0) { pageData.acf_fields = pg.acf; }
|
|
336
|
+
else { pageData.acf_fields = {}; pageData.acf_hint = 'ACF returned empty. Verify Show in REST API is enabled in each Field Group settings in WordPress Admin.'; }
|
|
337
|
+
result = json(pageData);
|
|
324
338
|
auditLog({ tool: name, target: args.id, target_type: 'page', action: 'read', status: 'success', latency_ms: Date.now() - t0 });
|
|
325
339
|
return result;
|
|
326
340
|
};
|
|
@@ -351,3 +365,38 @@ handlers['wp_update_page'] = async (args) => {
|
|
|
351
365
|
auditLog({ tool: name, target: id, target_type: 'page', action: 'update', status: 'success', latency_ms: Date.now() - t0, params: sanitizeParams(upd) });
|
|
352
366
|
return result;
|
|
353
367
|
};
|
|
368
|
+
handlers['wp_get_post_meta'] = async (args) => {
|
|
369
|
+
const t0 = Date.now();
|
|
370
|
+
const { wpApiCall, auditLog, name } = rt;
|
|
371
|
+
validateInput(args, { post_id: { type: 'number', required: true, min: 1 }, post_type: { type: 'string', enum: ['posts', 'pages'] } });
|
|
372
|
+
const { post_id, post_type = 'posts', meta_key, parse_json = true } = args;
|
|
373
|
+
let meta, acfFields = {};
|
|
374
|
+
try {
|
|
375
|
+
const post = await wpApiCall(`/${post_type}/${post_id}?_fields=meta,acf`);
|
|
376
|
+
meta = post?.meta ?? {};
|
|
377
|
+
acfFields = post?.acf ?? {};
|
|
378
|
+
} catch (e) {
|
|
379
|
+
if (post_type === 'posts' && e.message.includes('404')) {
|
|
380
|
+
const post = await wpApiCall(`/pages/${post_id}?_fields=meta,acf`);
|
|
381
|
+
meta = post?.meta ?? {};
|
|
382
|
+
acfFields = post?.acf ?? {};
|
|
383
|
+
} else { throw e; }
|
|
384
|
+
}
|
|
385
|
+
// Auto-parse JSON strings
|
|
386
|
+
if (parse_json) {
|
|
387
|
+
for (const [k, v] of Object.entries(meta)) {
|
|
388
|
+
if (typeof v === 'string' && v.length > 1 && (v[0] === '[' || v[0] === '{')) {
|
|
389
|
+
try { meta[k] = JSON.parse(v); } catch { /* keep as string */ }
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
let result;
|
|
394
|
+
if (meta_key) {
|
|
395
|
+
const value = meta[meta_key] !== undefined ? meta[meta_key] : (acfFields[meta_key] !== undefined ? acfFields[meta_key] : null);
|
|
396
|
+
result = json({ post_id, meta_key, value });
|
|
397
|
+
} else {
|
|
398
|
+
result = json({ post_id, meta, acf_fields: acfFields, source: Object.keys(acfFields).length > 0 ? 'acf_rest' : 'wp_meta_only' });
|
|
399
|
+
}
|
|
400
|
+
auditLog({ tool: name, target: post_id, action: 'read_meta', status: 'success', latency_ms: Date.now() - t0, params: { meta_key: meta_key || 'all' } });
|
|
401
|
+
return result;
|
|
402
|
+
};
|
package/src/tools/index.js
CHANGED
|
@@ -30,7 +30,7 @@ export const toolModules = [
|
|
|
30
30
|
fse, health, performance, schema, security, editorial,
|
|
31
31
|
];
|
|
32
32
|
|
|
33
|
-
/** All static definitions (
|
|
33
|
+
/** All static definitions (176 tools), unfiltered */
|
|
34
34
|
export const ALL_DEFINITIONS = toolModules.flatMap(m => m.definitions);
|
|
35
35
|
|
|
36
36
|
/** All handlers merged into a single lookup */
|
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);
|
|
@@ -137,12 +137,50 @@ describe('ACF Adapter', () => {
|
|
|
137
137
|
expect(data._warning).toContain('truncated');
|
|
138
138
|
});
|
|
139
139
|
|
|
140
|
-
// 5. acf_get_fields — 404
|
|
141
|
-
it('acf_get_fields
|
|
140
|
+
// 5. acf_get_fields — fallback to WP Core meta on 404
|
|
141
|
+
it('acf_get_fields falls back to WP Core meta when acf/v3 returns 404', async () => {
|
|
142
|
+
let callCount = 0;
|
|
143
|
+
const fallbackApi = async (endpoint, opts) => {
|
|
144
|
+
callCount++;
|
|
145
|
+
if (opts?.basePath === '/wp-json/acf/v3') throw new Error('WP API 404: Not Found');
|
|
146
|
+
// WP Core fallback
|
|
147
|
+
return { meta: { hero_title: 'From Core', _elementor_data: '[]' } };
|
|
148
|
+
};
|
|
142
149
|
const tool = acfAdapter.getTools().find(t => t.name === 'acf_get_fields');
|
|
143
|
-
await
|
|
144
|
-
|
|
145
|
-
).
|
|
150
|
+
const result = await tool.handler({ id: 42 }, fallbackApi);
|
|
151
|
+
const data = parseResult(result);
|
|
152
|
+
expect(data.source).toBe('wp_core_meta');
|
|
153
|
+
expect(data.warning).toContain('ACF REST API not available');
|
|
154
|
+
expect(data.acf.hero_title).toBe('From Core');
|
|
155
|
+
expect(callCount).toBe(2);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// 5b. acf_get_fields — fallback via ?_fields=acf when acf/v3 returns empty
|
|
159
|
+
it('acf_get_fields falls back to wp/v2?_fields=acf when acf/v3 returns empty', async () => {
|
|
160
|
+
let calls = [];
|
|
161
|
+
const fallbackApi = async (endpoint, opts) => {
|
|
162
|
+
calls.push({ endpoint, basePath: opts?.basePath });
|
|
163
|
+
if (opts?.basePath === '/wp-json/acf/v3') return { acf: {} };
|
|
164
|
+
if (endpoint.includes('_fields=acf')) return { acf: { hero: 'from_wp_rest' } };
|
|
165
|
+
return {};
|
|
166
|
+
};
|
|
167
|
+
const tool = acfAdapter.getTools().find(t => t.name === 'acf_get_fields');
|
|
168
|
+
const result = await tool.handler({ id: 42 }, fallbackApi);
|
|
169
|
+
const data = parseResult(result);
|
|
170
|
+
expect(data.acf.hero).toBe('from_wp_rest');
|
|
171
|
+
expect(data.source).toBe('wp_rest_acf_field');
|
|
172
|
+
expect(calls).toHaveLength(2);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// 5c. acf_get_fields — diagnostic warning when empty
|
|
176
|
+
it('acf_get_fields returns diagnostic when fields are empty', async () => {
|
|
177
|
+
const tool = acfAdapter.getTools().find(t => t.name === 'acf_get_fields');
|
|
178
|
+
const result = await tool.handler({ id: 42 }, mockApi({ acf: {} }));
|
|
179
|
+
const data = parseResult(result);
|
|
180
|
+
expect(data.fields_count).toBe(0);
|
|
181
|
+
expect(data.warning).toContain('No ACF fields returned');
|
|
182
|
+
expect(data.causes).toHaveLength(4);
|
|
183
|
+
expect(data.causes[0]).toContain('Show in REST API');
|
|
146
184
|
});
|
|
147
185
|
|
|
148
186
|
// 6. acf_list_field_groups — success
|
|
@@ -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', () => {
|
|
@@ -111,11 +111,11 @@ describe('Combined filtering counts', () => {
|
|
|
111
111
|
process.env.WC_CONSUMER_KEY = 'ck_test';
|
|
112
112
|
process.env.WP_REQUIRE_APPROVAL = 'true';
|
|
113
113
|
process.env.WP_ENABLE_PLUGIN_INTELLIGENCE = 'true';
|
|
114
|
-
expect(getFilteredTools()).toHaveLength(
|
|
114
|
+
expect(getFilteredTools()).toHaveLength(176);
|
|
115
115
|
});
|
|
116
116
|
|
|
117
117
|
it('returns 145 tools with no optional features (174 - 20wc - 3editorial - 6pi)', () => {
|
|
118
|
-
expect(getFilteredTools()).toHaveLength(
|
|
118
|
+
expect(getFilteredTools()).toHaveLength(147);
|
|
119
119
|
});
|
|
120
120
|
});
|
|
121
121
|
|
|
@@ -188,13 +188,13 @@ describe('WP_TOOL_CATEGORIES filtering', () => {
|
|
|
188
188
|
process.env.WP_TOOL_CATEGORIES = '';
|
|
189
189
|
const tools = getFilteredTools();
|
|
190
190
|
// Same as default (no category filter) — 143 base tools
|
|
191
|
-
expect(tools).toHaveLength(
|
|
191
|
+
expect(tools).toHaveLength(147);
|
|
192
192
|
});
|
|
193
193
|
|
|
194
194
|
it('WP_TOOL_CATEGORIES undefined → all tools exposed', () => {
|
|
195
195
|
delete process.env.WP_TOOL_CATEGORIES;
|
|
196
196
|
const tools = getFilteredTools();
|
|
197
|
-
expect(tools).toHaveLength(
|
|
197
|
+
expect(tools).toHaveLength(147);
|
|
198
198
|
});
|
|
199
199
|
|
|
200
200
|
it('core tools (wp_site_info, wp_set_target) always present regardless of config', () => {
|
|
@@ -216,7 +216,7 @@ describe('WP_TOOL_CATEGORIES filtering', () => {
|
|
|
216
216
|
});
|
|
217
217
|
|
|
218
218
|
it('total TOOLS_DEFINITIONS count is 174', () => {
|
|
219
|
-
expect(TOOLS_DEFINITIONS).toHaveLength(
|
|
219
|
+
expect(TOOLS_DEFINITIONS).toHaveLength(176);
|
|
220
220
|
});
|
|
221
221
|
|
|
222
222
|
it('getEnabledCategories reflects WP_TOOL_CATEGORIES', () => {
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
vi.mock('node-fetch', () => ({ default: vi.fn() }));
|
|
4
|
+
|
|
5
|
+
import fetch from 'node-fetch';
|
|
6
|
+
import { handleToolCall } from '../../../index.js';
|
|
7
|
+
import { mockSuccess, mockError, makeRequest, parseResult } from '../../helpers/mockWpRequest.js';
|
|
8
|
+
|
|
9
|
+
function call(name, args = {}) {
|
|
10
|
+
return handleToolCall(makeRequest(name, args));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let consoleSpy;
|
|
14
|
+
beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
|
|
15
|
+
afterEach(() => { consoleSpy.mockRestore(); });
|
|
16
|
+
|
|
17
|
+
// =========================================================================
|
|
18
|
+
// wp_get_post_meta
|
|
19
|
+
// =========================================================================
|
|
20
|
+
|
|
21
|
+
describe('wp_get_post_meta', () => {
|
|
22
|
+
it('SUCCESS — returns all meta fields', async () => {
|
|
23
|
+
mockSuccess({ meta: { _yoast_wpseo_title: 'SEO Title', custom_field: 'value' } });
|
|
24
|
+
|
|
25
|
+
const res = await call('wp_get_post_meta', { post_id: 42 });
|
|
26
|
+
const data = parseResult(res);
|
|
27
|
+
|
|
28
|
+
expect(data.post_id).toBe(42);
|
|
29
|
+
expect(data.meta._yoast_wpseo_title).toBe('SEO Title');
|
|
30
|
+
expect(data.meta.custom_field).toBe('value');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('SUCCESS — filters by meta_key', async () => {
|
|
34
|
+
mockSuccess({ meta: { _yoast_wpseo_title: 'SEO Title', other: 'x' } });
|
|
35
|
+
|
|
36
|
+
const res = await call('wp_get_post_meta', { post_id: 42, meta_key: '_yoast_wpseo_title' });
|
|
37
|
+
const data = parseResult(res);
|
|
38
|
+
|
|
39
|
+
expect(data.post_id).toBe(42);
|
|
40
|
+
expect(data.meta_key).toBe('_yoast_wpseo_title');
|
|
41
|
+
expect(data.value).toBe('SEO Title');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('SUCCESS — auto-parses JSON strings', async () => {
|
|
45
|
+
mockSuccess({ meta: { _elementor_data: '[{"elType":"section"}]', plain: 'text' } });
|
|
46
|
+
|
|
47
|
+
const res = await call('wp_get_post_meta', { post_id: 10, parse_json: true });
|
|
48
|
+
const data = parseResult(res);
|
|
49
|
+
|
|
50
|
+
expect(Array.isArray(data.meta._elementor_data)).toBe(true);
|
|
51
|
+
expect(data.meta._elementor_data[0].elType).toBe('section');
|
|
52
|
+
expect(data.meta.plain).toBe('text');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('FALLBACK — retries on pages if posts returns 404', async () => {
|
|
56
|
+
// First call (posts) → 404
|
|
57
|
+
fetch.mockImplementationOnce(() => Promise.resolve({
|
|
58
|
+
ok: false, status: 404,
|
|
59
|
+
headers: { get: () => null },
|
|
60
|
+
text: () => Promise.resolve('Not found')
|
|
61
|
+
}));
|
|
62
|
+
// Second call (pages) → success
|
|
63
|
+
mockSuccess({ meta: { page_field: 'from_pages' } });
|
|
64
|
+
|
|
65
|
+
const res = await call('wp_get_post_meta', { post_id: 5, post_type: 'posts' });
|
|
66
|
+
const data = parseResult(res);
|
|
67
|
+
|
|
68
|
+
expect(data.post_id).toBe(5);
|
|
69
|
+
expect(data.meta.page_field).toBe('from_pages');
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// =========================================================================
|
|
74
|
+
// wp_get_post — ACF fields integration
|
|
75
|
+
// =========================================================================
|
|
76
|
+
|
|
77
|
+
describe('wp_get_post — ACF fields', () => {
|
|
78
|
+
const fullPost = (acf) => ({
|
|
79
|
+
id: 1, title: { rendered: 'T' }, content: { rendered: '' }, excerpt: { rendered: '' },
|
|
80
|
+
status: 'publish', date: '2026-01-01', modified: '2026-01-01', link: 'https://test.example.com/?p=1',
|
|
81
|
+
slug: 'test', categories: [], tags: [], author: 1, featured_media: 0, comment_status: 'open', meta: {},
|
|
82
|
+
acf,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('includes acf_fields when post.acf has data', async () => {
|
|
86
|
+
mockSuccess(fullPost({ hero_title: 'Hello', price: 29 }));
|
|
87
|
+
|
|
88
|
+
const res = await call('wp_get_post', { id: 1 });
|
|
89
|
+
const data = parseResult(res);
|
|
90
|
+
|
|
91
|
+
expect(data.acf_fields.hero_title).toBe('Hello');
|
|
92
|
+
expect(data.acf_fields.price).toBe(29);
|
|
93
|
+
expect(data.acf_hint).toBeUndefined();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('includes acf_hint when post.acf is empty', async () => {
|
|
97
|
+
mockSuccess(fullPost({}));
|
|
98
|
+
|
|
99
|
+
const res = await call('wp_get_post', { id: 1 });
|
|
100
|
+
const data = parseResult(res);
|
|
101
|
+
|
|
102
|
+
expect(data.acf_fields).toEqual({});
|
|
103
|
+
expect(data.acf_hint).toContain('Show in REST API');
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -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
|
|
|
@@ -80,7 +80,7 @@ describe('wp_site_info', () => {
|
|
|
80
80
|
|
|
81
81
|
// Server info
|
|
82
82
|
expect(data.server.mcp_version).toBeDefined();
|
|
83
|
-
expect(data.server.tools_total).toBe(
|
|
83
|
+
expect(data.server.tools_total).toBe(176);
|
|
84
84
|
expect(typeof data.server.tools_exposed).toBe('number');
|
|
85
85
|
expect(Array.isArray(data.server.filtered_out)).toBe(true);
|
|
86
86
|
});
|