@adsim/wordpress-mcp-server 5.3.1 → 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.
@@ -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.1",
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",
@@ -7,7 +7,7 @@ import { rt } from '../shared/context.js';
7
7
 
8
8
  export const definitions = [
9
9
  { name: 'wp_list_posts', _category: 'content', description: 'Use to browse/filter posts by status, category, tag, author, or search. Read-only. Hint: use mode=\'summary\' for listings, \'ids_only\' for batch ops.', inputSchema: { type: 'object', properties: { per_page: { type: 'number', default: 10 }, page: { type: 'number', default: 1 }, status: { type: 'string', default: 'publish' }, orderby: { type: 'string', default: 'date' }, order: { type: 'string', default: 'desc' }, categories: { type: 'string' }, tags: { type: 'string' }, search: { type: 'string' }, author: { type: 'number' }, mode: { type: 'string', default: 'full', description: 'full=all fields, summary=id/title/slug/date/status/link only, ids_only=flat ID array' } }}},
10
- { name: 'wp_get_post', _category: 'content', description: 'Use to read a single post with full content and meta. Read-only. Hint: use content_format=\'links_only\' for link audits (~800 chars vs 187k), \'text\' for rewrites, fields=[\'id\',\'title\',\'meta\'] for SEO-only.', inputSchema: { type: 'object', properties: { id: { type: 'number' }, fields: { type: 'array', items: { type: 'string' }, description: 'Return only specified fields (id,title,content,excerpt,slug,status,date,modified,categories,tags,author,featured_media,meta,link). Omit for all.' }, content_format: { type: 'string', default: 'html', description: 'html=raw HTML (truncated at WP_MAX_CONTENT_CHARS), text=plain text, links_only=extract internal links only' } }, required: ['id'] }},
10
+ { name: 'wp_get_post', _category: 'content', description: 'Use to read a single post with full content and meta. Read-only. Hint: use content_format=\'links_only\' for link audits (~800 chars vs 187k), \'text\' for rewrites, fields=[\'id\',\'title\',\'meta\'] for SEO-only.', inputSchema: { type: 'object', properties: { id: { type: 'number' }, fields: { type: 'array', items: { type: 'string' }, description: 'Return only specified fields (id,title,content,excerpt,slug,status,date,modified,categories,tags,author,featured_media,meta,link). Omit for all.' }, content_format: { type: 'string', enum: ['html', 'text', 'links_only', 'raw'], default: 'html', description: 'html=rendered HTML (truncated at WP_MAX_CONTENT_CHARS), text=plain text, links_only=extract internal links only, raw=Gutenberg source with block comments via ?context=edit (NEVER truncated — required before any read-modify-write of content)' } }, required: ['id'] }},
11
11
  { name: 'wp_create_post', _category: 'content', description: 'Use to create a post (defaults to draft). Accepts title, HTML content, categories, tags, featured_media, meta. Write — blocked by WP_READ_ONLY.', inputSchema: { type: 'object', properties: { title: { type: 'string' }, content: { type: 'string' }, status: { type: 'string', default: 'draft' }, excerpt: { type: 'string' }, categories: { type: 'array', items: { type: 'number' } }, tags: { type: 'array', items: { type: 'number' } }, slug: { type: 'string' }, featured_media: { type: 'number' }, meta: { type: 'object' }, author: { type: 'number' } }, required: ['title', 'content'] }},
12
12
  { name: 'wp_update_post', _category: 'content', description: 'Use to modify any post field — only provided fields change. Write — blocked by WP_READ_ONLY.', inputSchema: { type: 'object', properties: { id: { type: 'number' }, title: { type: 'string' }, content: { type: 'string' }, status: { type: 'string' }, excerpt: { type: 'string' }, categories: { type: 'array', items: { type: 'number' } }, tags: { type: 'array', items: { type: 'number' } }, slug: { type: 'string' }, featured_media: { type: 'number' }, meta: { type: 'object' }, author: { type: 'number' } }, required: ['id'] }},
13
13
  { name: 'wp_delete_post', _category: 'content', description: 'Use to trash or permanently delete a post. Write — blocked by WP_READ_ONLY, WP_DISABLE_DELETE.', inputSchema: { type: 'object', properties: { id: { type: 'number' }, force: { type: 'boolean', default: false }, confirmation_token: { type: 'string', description: 'Confirmation token returned by the first call when WP_CONFIRM_DESTRUCTIVE=true' } }, required: ['id'] }},
@@ -53,12 +53,19 @@ handlers['wp_get_post'] = async (args) => {
53
53
  const t0 = Date.now();
54
54
  let result;
55
55
  const { wpApiCall, getActiveAuth, auditLog, name, summarizePost, applyContentFormat } = rt;
56
- validateInput(args, { id: { type: 'number', required: true, min: 1 }, fields: { type: 'array' }, content_format: { type: 'string', enum: ['html', 'text', 'links_only'] } });
56
+ validateInput(args, { id: { type: 'number', required: true, min: 1 }, fields: { type: 'array' }, content_format: { type: 'string', enum: ['html', 'text', 'links_only', 'raw'] } });
57
57
  const { content_format = 'html', fields: requestedFields } = args;
58
- const p = await wpApiCall(`/posts/${args.id}`);
58
+ // raw = source Gutenberg (block comments) : REST ?context=edit expose
59
+ // content.raw / title.raw (droits d'édition requis — déjà le cas ici).
60
+ const p = await wpApiCall(`/posts/${args.id}${content_format === 'raw' ? '?context=edit' : ''}`);
59
61
  let postData = { id: p.id, title: p.title.rendered, content: p.content.rendered, excerpt: p.excerpt.rendered, status: p.status, date: p.date, modified: p.modified, link: p.link, slug: p.slug, categories: p.categories, tags: p.tags, author: p.author, featured_media: p.featured_media, comment_status: p.comment_status, meta: p.meta || {} };
60
62
  if (p.acf && Object.keys(p.acf).length > 0) { postData.acf_fields = p.acf; }
61
63
  else { postData.acf_fields = {}; postData.acf_hint = 'ACF returned empty. Verify Show in REST API is enabled in each Field Group settings in WordPress Admin.'; }
64
+ if (content_format === 'raw') {
65
+ postData.content = p.content.raw ?? p.content.rendered;
66
+ postData.title = p.title.raw ?? p.title.rendered;
67
+ postData.excerpt = p.excerpt.raw ?? p.excerpt.rendered;
68
+ }
62
69
  const { url: siteUrl } = getActiveAuth();
63
70
  postData = applyContentFormat(postData, content_format, siteUrl);
64
71
  if (requestedFields && requestedFields.length > 0) postData = summarizePost(postData, requestedFields);
package/src/tools/seo.js CHANGED
@@ -191,11 +191,63 @@ handlers['wp_update_seo_meta'] = async (args) => {
191
191
 
192
192
  if (updated.length === 0) throw new Error('No SEO fields provided. Specify at least one of: title, description, focus_keyword, canonical_url, robots_noindex, robots_nofollow.');
193
193
 
194
- const writeEp = post_type === 'page' ? `/pages/${id}` : `/posts/${id}`;
195
- await wpApiCall(writeEp, { method: 'POST', body: JSON.stringify({ meta: metaUpdate }) });
194
+ // ── Strategy 1: Try MCP Companion direct write (uses update_post_meta, bypasses Yoast block) ──
195
+ let writeMethod = 'rest_api';
196
+ let verified = true;
197
+ const failedFields = [];
196
198
 
197
- result = json({ success: true, message: `SEO meta updated for ${post_type} ${id}`, plugin, fields_updated: updated, meta_written: metaUpdate });
198
- auditLog({ tool: name, target: id, target_type: post_type, action: 'update_seo', status: 'success', latency_ms: Date.now() - t0, params: { plugin, fields: updated } });
199
+ try {
200
+ const companionResult = await wpApiCall(`/${id}`, {
201
+ method: 'POST',
202
+ basePath: '/wp-json/mcp-diagnostics/v1/seo-meta',
203
+ body: JSON.stringify({ meta: metaUpdate }),
204
+ });
205
+ // Companion returns { written: [...], verified: { key: value } }
206
+ if (companionResult?.written?.length > 0) {
207
+ writeMethod = 'companion_direct';
208
+ // Verify from companion response
209
+ for (const [metaKey, expectedVal] of Object.entries(metaUpdate)) {
210
+ const actual = companionResult.verified?.[metaKey];
211
+ if (actual === undefined || actual === null || String(actual) !== String(expectedVal)) {
212
+ failedFields.push({ key: metaKey, expected: expectedVal, actual: actual ?? null });
213
+ }
214
+ }
215
+ if (failedFields.length > 0) verified = false;
216
+ }
217
+ } catch {
218
+ // Companion not installed — fall back to REST API
219
+ writeMethod = 'rest_api';
220
+ }
221
+
222
+ // ── Strategy 2: Standard REST API (fallback when companion not available) ──
223
+ if (writeMethod === 'rest_api') {
224
+ const writeEp = post_type === 'page' ? `/pages/${id}` : `/posts/${id}`;
225
+ await wpApiCall(writeEp, { method: 'POST', body: JSON.stringify({ meta: metaUpdate }) });
226
+
227
+ // Verify write: read back meta to detect silent failures (Yoast issue)
228
+ try {
229
+ const verify = await wpApiCall(readEp);
230
+ const verifyMeta = verify.meta || {};
231
+ for (const [metaKey, expectedVal] of Object.entries(metaUpdate)) {
232
+ const actual = verifyMeta[metaKey];
233
+ if (actual === undefined || actual === null || String(actual) !== String(expectedVal)) {
234
+ failedFields.push({ key: metaKey, expected: expectedVal, actual: actual ?? null });
235
+ }
236
+ }
237
+ if (failedFields.length > 0) verified = false;
238
+ } catch { verified = false; }
239
+ }
240
+
241
+ const response = { success: verified, message: `SEO meta updated for ${post_type} ${id}`, plugin, fields_updated: updated, meta_written: metaUpdate, verified, write_method: writeMethod };
242
+ if (!verified && failedFields.length > 0) {
243
+ response.failed_fields = failedFields;
244
+ response.warning = `Silent write failure detected: ${failedFields.length} field(s) were not saved by WordPress. This is a known issue with Yoast SEO which blocks REST API writes to its proprietary meta fields.`;
245
+ response.fix = 'Install the MCP Companion plugin (companion/mcp-diagnostics.php) as a must-use plugin to enable direct update_post_meta() writes that bypass Yoast restrictions. Copy it to wp-content/mu-plugins/mcp-diagnostics.php and retry.';
246
+ auditLog({ tool: name, target: id, target_type: post_type, action: 'update_seo', status: 'silent_failure', latency_ms: Date.now() - t0, params: { plugin, fields: updated, failed: failedFields.map(f => f.key), write_method: writeMethod } });
247
+ } else {
248
+ auditLog({ tool: name, target: id, target_type: post_type, action: 'update_seo', status: 'success', latency_ms: Date.now() - t0, params: { plugin, fields: updated, write_method: writeMethod } });
249
+ }
250
+ result = json(response);
199
251
  return result;
200
252
  };
201
253
  handlers['wp_audit_seo'] = async (args) => {
@@ -88,7 +88,7 @@ export function summarizePost(post, fields) {
88
88
  /**
89
89
  * Apply content_format transformation to a post object.
90
90
  * @param {object} post Post with .content field
91
- * @param {string} contentFormat 'html' | 'text' | 'links_only'
91
+ * @param {string} contentFormat 'html' | 'text' | 'links_only' | 'raw'
92
92
  * @param {string} siteUrl Site base URL
93
93
  * @param {number} maxChars Truncation limit (0 = unlimited)
94
94
  * @returns {object}
@@ -106,6 +106,11 @@ export function applyContentFormat(post, contentFormat, siteUrl, maxChars = DEFA
106
106
  processed.internal_links = extractLinksOnly(post.content, siteUrl);
107
107
  processed._content_format = 'links_only';
108
108
  break;
109
+ case 'raw':
110
+ // Source Gutenberg destinée à être réécrite telle quelle :
111
+ // AUCUNE troncature (un raw tronqué corromprait le post à l'update).
112
+ processed._content_format = 'raw';
113
+ break;
109
114
  case 'html':
110
115
  default:
111
116
  processed.content = truncateContent(post.content, maxChars);
@@ -143,6 +143,13 @@ describe('contentCompressor — summarizePost', () => {
143
143
  });
144
144
 
145
145
  describe('contentCompressor — applyContentFormat', () => {
146
+ it('raw : passthrough intégral, jamais tronqué', () => {
147
+ const long = '<!-- wp:paragraph -->' + 'x'.repeat(50000) + '<!-- /wp:paragraph -->';
148
+ const result = applyContentFormat({ content: long }, 'raw', 'https://s.tld', 100);
149
+ expect(result.content).toBe(long);
150
+ expect(result._content_format).toBe('raw');
151
+ });
152
+
146
153
  const siteUrl = 'https://mysite.example.com';
147
154
 
148
155
  it('html mode — truncates long content', () => {
@@ -52,6 +52,41 @@ describe('wp_list_posts', () => {
52
52
  // ────────────────────────────────────────────────────────────
53
53
 
54
54
  describe('wp_get_post', () => {
55
+ describe('content_format=raw', () => {
56
+ const RAW = '<!-- wp:paragraph --><p>Source brute</p><!-- /wp:paragraph -->';
57
+ const rawPost = {
58
+ id: 1,
59
+ title: { rendered: 'Rendu', raw: 'Brut' },
60
+ content: { rendered: '<p>Source brute</p>', raw: RAW },
61
+ excerpt: { rendered: 'ex', raw: 'ex brut' },
62
+ status: 'publish', date: '2024-01-01', modified: '2024-01-01',
63
+ link: 'https://test.example.com/post-1', slug: 'post-1',
64
+ categories: [], tags: [], author: 1, featured_media: 0,
65
+ comment_status: 'open', meta: {},
66
+ };
67
+
68
+ it('appelle la REST API avec ?context=edit', async () => {
69
+ fetch.mockResolvedValue(mockSuccess(rawPost));
70
+ await call('wp_get_post', { id: 1, content_format: 'raw' });
71
+ expect(String(fetch.mock.calls[0][0])).toContain('/posts/1?context=edit');
72
+ });
73
+
74
+ it('retourne content.raw et title.raw sans troncature', async () => {
75
+ fetch.mockResolvedValue(mockSuccess(rawPost));
76
+ const data = parseResult(await call('wp_get_post', { id: 1, content_format: 'raw' }));
77
+ expect(data.content).toBe(RAW);
78
+ expect(data.title).toBe('Brut');
79
+ expect(data._content_format).toBe('raw');
80
+ });
81
+
82
+ it('sans raw, pas de context=edit et contenu rendu', async () => {
83
+ fetch.mockResolvedValue(mockSuccess(rawPost));
84
+ const data = parseResult(await call('wp_get_post', { id: 1 }));
85
+ expect(String(fetch.mock.calls[0][0])).not.toContain('context=edit');
86
+ expect(data.content).toBe('<p>Source brute</p>');
87
+ });
88
+ });
89
+
55
90
  let consoleSpy;
56
91
  beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
57
92
  afterEach(() => { consoleSpy.mockRestore(); });
@@ -105,21 +105,70 @@ describe('wp_get_seo_meta', () => {
105
105
  // =========================================================================
106
106
 
107
107
  describe('wp_update_seo_meta', () => {
108
- it('SUCCESS detects Yoast and updates title + description', async () => {
108
+ it('SUCCESS via companion uses direct update_post_meta', async () => {
109
109
  // First fetch: read current post to detect plugin
110
110
  mockSuccess(yoastPost);
111
- // Second fetch: write the meta update
112
- mockSuccess({});
111
+ // Second fetch: companion endpoint succeeds with verified values
112
+ mockSuccess({
113
+ written: ['_yoast_wpseo_title', '_yoast_wpseo_metadesc'],
114
+ verified: { _yoast_wpseo_title: 'New SEO Title', _yoast_wpseo_metadesc: 'New desc' },
115
+ source: 'direct_update_post_meta',
116
+ });
113
117
 
114
118
  const res = await call('wp_update_seo_meta', { id: 1, title: 'New SEO Title', description: 'New desc' });
115
119
  const data = parseResult(res);
116
120
 
117
121
  expect(data.success).toBe(true);
122
+ expect(data.verified).toBe(true);
123
+ expect(data.write_method).toBe('companion_direct');
118
124
  expect(data.plugin).toBe('yoast');
119
125
  expect(data.fields_updated).toContain('title');
120
126
  expect(data.fields_updated).toContain('description');
121
127
  });
122
128
 
129
+ it('SUCCESS via REST fallback — companion not installed', async () => {
130
+ // First fetch: read current post to detect plugin
131
+ mockSuccess(yoastPost);
132
+ // Second fetch: companion endpoint → 404 (not installed)
133
+ mockError(404, '{"code":"rest_no_route"}');
134
+ // Third fetch: standard REST write
135
+ mockSuccess({});
136
+ // Fourth fetch: verification read — meta reflects the written values
137
+ mockSuccess({ ...yoastPost, meta: { ...yoastPost.meta, _yoast_wpseo_title: 'New SEO Title', _yoast_wpseo_metadesc: 'New desc' } });
138
+
139
+ const res = await call('wp_update_seo_meta', { id: 1, title: 'New SEO Title', description: 'New desc' });
140
+ const data = parseResult(res);
141
+
142
+ expect(data.success).toBe(true);
143
+ expect(data.verified).toBe(true);
144
+ expect(data.write_method).toBe('rest_api');
145
+ expect(data.plugin).toBe('yoast');
146
+ });
147
+
148
+ it('SILENT FAILURE — companion unavailable and REST write blocked by Yoast', async () => {
149
+ // First fetch: read current post to detect plugin
150
+ mockSuccess(yoastPost);
151
+ // Second fetch: companion → 404
152
+ mockError(404, '{"code":"rest_no_route"}');
153
+ // Third fetch: REST write — WordPress returns success but does not save
154
+ mockSuccess({});
155
+ // Fourth fetch: verification read — meta unchanged (silent failure)
156
+ mockSuccess(yoastPost);
157
+
158
+ const res = await call('wp_update_seo_meta', { id: 1, focus_keyword: 'new keyword' });
159
+ const data = parseResult(res);
160
+
161
+ expect(data.success).toBe(false);
162
+ expect(data.verified).toBe(false);
163
+ expect(data.write_method).toBe('rest_api');
164
+ expect(data.failed_fields).toBeDefined();
165
+ expect(data.failed_fields.length).toBeGreaterThan(0);
166
+ expect(data.failed_fields[0].key).toBe('_yoast_wpseo_focuskw');
167
+ expect(data.failed_fields[0].expected).toBe('new keyword');
168
+ expect(data.warning).toContain('Silent write failure');
169
+ expect(data.fix).toContain('mcp-diagnostics.php');
170
+ });
171
+
123
172
  it('GOVERNANCE — blocked by WP_READ_ONLY', async () => {
124
173
  const original = process.env.WP_READ_ONLY;
125
174
  process.env.WP_READ_ONLY = 'true';
@@ -152,7 +201,11 @@ describe('wp_update_seo_meta', () => {
152
201
 
153
202
  it('AUDIT — logs update_seo action on success', async () => {
154
203
  mockSuccess(yoastPost);
155
- mockSuccess({});
204
+ // Companion succeeds
205
+ mockSuccess({
206
+ written: ['_yoast_wpseo_title'],
207
+ verified: { _yoast_wpseo_title: 'Audit Test' },
208
+ });
156
209
 
157
210
  await call('wp_update_seo_meta', { id: 1, title: 'Audit Test' });
158
211