PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.1
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.1
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
metasync / wp-mcp-server / tools / class-mcp-tool-post-meta.php

class-mcp-tool-post-meta.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.7.1, at wp-mcp-server/tools/class-mcp-tool-post-meta.php

651 lines 25.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * MCP Tool: Post Meta Operations
4 *
5 * Provides tools for managing WordPress post meta fields,
6 * specifically SEO-related metadata.
7 *
8 * @package MetaSync
9 * @subpackage MCP_Server/Tools
10 */
11
12 if (!defined('ABSPATH')) {
13 exit;
14 }
15
16 /**
17 * Update Post Meta Tool
18 */
19 class MCP_Tool_Update_Post_Meta extends MCP_Tool_Base {
20
21 public function get_name() {
22 return 'wordpress_update_post_meta';
23 }
24
25 public function get_description() {
26 return 'Update a WordPress post meta field (SEO data like title, description, keywords, robots settings, Open Graph/social meta, hreflang/language alternates)';
27 }
28
29 public function get_input_schema() {
30 return [
31 'type' => 'object',
32 'properties' => [
33 'post_id' => [
34 'type' => 'integer',
35 'description' => 'WordPress post or page ID',
36 'minimum' => 1
37 ],
38 'meta_key' => [
39 'type' => 'string',
40 'description' => 'Meta field to update',
41 'enum' => [
42 '_metasync_metatitle',
43 '_metasync_metadesc',
44 '_metasync_focus_keyword',
45 '_metasync_robots_index',
46 '_metasync_canonical_url',
47 '_metasync_og_enabled',
48 '_metasync_og_title',
49 '_metasync_og_description',
50 '_metasync_og_image',
51 '_metasync_og_url',
52 '_metasync_og_type',
53 '_metasync_twitter_title',
54 '_metasync_twitter_description',
55 '_metasync_twitter_card',
56 '_metasync_primary_category',
57 '_metasync_otto_keywords',
58 '_metasync_og_article_author',
59 '_metasync_hreflang',
60 '_metasync_breadcrumb_title',
61 '_metasync_robots_advanced'
62 ]
63 ],
64 'meta_value' => [
65 'type' => 'string',
66 'description' => 'Value to set for the meta field'
67 ]
68 ],
69 'required' => ['post_id', 'meta_key', 'meta_value']
70 ];
71 }
72
73 public function execute($params) {
74 // Validate and sanitize
75 $this->validate_params($params);
76 $this->require_capability('edit_posts');
77
78 $post_id = $this->sanitize_integer($params['post_id']);
79 $meta_key = $this->sanitize_string($params['meta_key']);
80 $meta_value = $this->sanitize_textarea($params['meta_value']);
81
82 // hreflang / language alternates: value must be a JSON array of
83 // {lang, url} objects (with optional region). Parse from the raw
84 // param to avoid textarea sanitization mangling the JSON, validate
85 // shape AND values (ISO language codes, absolute http(s) URLs),
86 // normalise, and re-encode to canonical form before storing.
87 if ($meta_key === '_metasync_hreflang') {
88 $raw_value = isset($params['meta_value']) ? (string) $params['meta_value'] : '';
89 $decoded = json_decode($raw_value, true);
90 if (!is_array($decoded)) {
91 throw new Exception("_metasync_hreflang must be a JSON array");
92 }
93 $clean = [];
94 foreach ($decoded as $entry) {
95 if (!is_array($entry) || !isset($entry['lang']) || !isset($entry['url'])) {
96 throw new Exception("Each hreflang entry must have 'lang' and 'url' keys");
97 }
98 $normalized = Metasync_Hreflang_Output::normalize_entry($entry);
99 if (!$normalized['valid']) {
100 throw new Exception(sprintf(
101 "Invalid hreflang language code '%s' — use an ISO code like 'en', 'en-US' (hyphen, not underscore) or 'x-default'",
102 is_scalar($entry['lang']) ? (string) $entry['lang'] : gettype($entry['lang'])
103 ));
104 }
105 // Sanitize each URL to strip javascript: and other unsafe protocols.
106 $url = esc_url_raw(trim((string) $entry['url']));
107 if (!Metasync_Hreflang_Output::is_absolute_http_url($url)) {
108 throw new Exception(sprintf(
109 "hreflang URL '%s' must be an absolute http(s) URL (relative paths are not valid hreflang hrefs)",
110 (string) $entry['url']
111 ));
112 }
113 $clean[] = [
114 'lang' => $normalized['lang'],
115 'region' => $normalized['region'],
116 'url' => $url,
117 ];
118 }
119 // update_metadata() runs wp_unslash() on the value; combined with
120 // the default \uXXXX escaping this corrupted non-ASCII URLs
121 // (…/über-uns/ stored as …/u00fcber-uns/). Encoding without the
122 // escapes and passing slashed data — the same contract the REST
123 // path uses — keeps the stored JSON byte-faithful.
124 $meta_value = wp_slash(wp_json_encode($clean, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
125 }
126
127 // Validate _metasync_robots_advanced JSON
128 if ($meta_key === '_metasync_robots_advanced') {
129 $raw_value = isset($params['meta_value']) ? (string) $params['meta_value'] : '';
130 $decoded = json_decode($raw_value, true);
131 if (!is_array($decoded)) {
132 throw new Exception("_metasync_robots_advanced must be a JSON object");
133 }
134 $allowed_keys = ['nofollow', 'noarchive', 'nosnippet', 'noimageindex', 'max_snippet', 'max_image_preview', 'max_video_preview'];
135 foreach (array_keys($decoded) as $k) {
136 if (!in_array($k, $allowed_keys, true)) {
137 throw new Exception("Unknown key '{$k}' in _metasync_robots_advanced. Allowed: " . implode(', ', $allowed_keys));
138 }
139 }
140 // Validate types
141 foreach (['nofollow', 'noarchive', 'nosnippet', 'noimageindex'] as $bool_key) {
142 if (isset($decoded[$bool_key]) && !is_bool($decoded[$bool_key])) {
143 $decoded[$bool_key] = (bool) $decoded[$bool_key];
144 }
145 }
146 if (isset($decoded['max_snippet'])) {
147 $decoded['max_snippet'] = (int) $decoded['max_snippet'];
148 }
149 if (isset($decoded['max_image_preview'])) {
150 $valid = ['none', 'standard', 'large'];
151 if (!in_array($decoded['max_image_preview'], $valid, true)) {
152 throw new Exception("max_image_preview must be one of: " . implode(', ', $valid));
153 }
154 }
155 if (isset($decoded['max_video_preview'])) {
156 $decoded['max_video_preview'] = (int) $decoded['max_video_preview'];
157 }
158 $meta_value = wp_json_encode($decoded);
159 }
160
161 // SECURITY: Apply esc_url_raw() to URL-typed meta keys (prevent javascript: protocol).
162 if (in_array($meta_key, ['_metasync_og_image', '_metasync_og_url', '_metasync_canonical_url', '_metasync_og_article_author'])) {
163 $meta_value = esc_url_raw($meta_value);
164 }
165
166 // Canonical must be a usable URL — reject corrupted values like
167 // "Array"/"http://Array" so they can't re-enter storage.
168 if ($meta_key === '_metasync_canonical_url') {
169 $meta_value = Metasync_Canonical_Sanitizer::sanitize_for_save($meta_value);
170 if ($meta_value === '') {
171 return $this->error('canonical_url must be a valid URL');
172 }
173 }
174
175 // Sanitize integer meta fields
176 if ($meta_key === '_metasync_primary_category') {
177 $meta_value = absint($meta_value);
178 }
179
180 // Verify post exists
181 $post = $this->verify_post_exists($post_id);
182
183 // SECURITY: Check user has permission to edit this specific post
184 $this->check_post_permission($post_id);
185
186 // Update meta
187 $current_value = get_post_meta($post_id, $meta_key, true);
188 // Normalize numeric meta keys: WordPress returns stored values as strings,
189 // so cast for type-safe comparison against the integer $meta_value above.
190 if ($meta_key === '_metasync_primary_category') {
191 $current_value = (int) $current_value;
192 }
193 // The hreflang payload is stored slashed (wp_slash) so it survives
194 // update_metadata()'s wp_unslash(); the stored value is therefore the
195 // UNSLASHED form. Compare against that, or an identical re-write would
196 // look like a change, run update_post_meta(), come back false ("value
197 // unchanged") and wrongly throw below.
198 $compare_value = $meta_key === '_metasync_hreflang' ? wp_unslash($meta_value) : $meta_value;
199 if ($current_value === $compare_value) {
200 // Value already matches — return success without update
201 return $this->success([
202 'post_id' => $post_id,
203 'meta_key' => $meta_key,
204 'meta_value' => $current_value,
205 'updated' => false,
206 'post_title' => $post->post_title,
207 'post_type' => $post->post_type
208 ], 'Meta value already matches, no update needed');
209 }
210 $updated = update_post_meta($post_id, $meta_key, $meta_value);
211
212 if ($updated === false) {
213 throw new Exception("Failed to update meta key '{$meta_key}'");
214 }
215
216 $stored_value = get_post_meta($post_id, $meta_key, true);
217 return $this->success([
218 'post_id' => $post_id,
219 'meta_key' => $meta_key,
220 'meta_value' => $stored_value,
221 'updated' => true,
222 'post_title' => $post->post_title,
223 'post_type' => $post->post_type
224 ], "Meta field '{$meta_key}' updated successfully");
225 }
226 }
227
228 /**
229 * Get Post Meta Tool
230 */
231 class MCP_Tool_Get_Post_Meta extends MCP_Tool_Base {
232
233 public function get_name() {
234 return 'wordpress_get_post_meta';
235 }
236
237 public function get_description() {
238 return 'Get WordPress post meta field value(s)';
239 }
240
241 public function get_input_schema() {
242 return [
243 'type' => 'object',
244 'properties' => [
245 'post_id' => [
246 'type' => 'integer',
247 'description' => 'WordPress post or page ID',
248 'minimum' => 1
249 ],
250 'meta_key' => [
251 'type' => 'string',
252 'description' => 'Specific meta key to retrieve (optional - omit to get all SEO meta)',
253 ]
254 ],
255 'required' => ['post_id']
256 ];
257 }
258
259 public function execute($params) {
260 $this->validate_params($params);
261 $this->require_capability('read');
262
263 $post_id = $this->sanitize_integer($params['post_id']);
264
265 // Verify post exists
266 $post = $this->verify_post_exists($post_id);
267
268 // SECURITY: Check user has permission to read this specific post
269 $this->check_post_permission($post_id);
270
271 // Get meta
272 if (isset($params['meta_key'])) {
273 $meta_key = $this->sanitize_string($params['meta_key']);
274 $meta_value = get_post_meta($post_id, $meta_key, true);
275
276 return $this->success([
277 'post_id' => $post_id,
278 'post_title' => $post->post_title,
279 'meta_key' => $meta_key,
280 'meta_value' => $meta_value
281 ]);
282 } else {
283 // Get all SEO meta
284 $seo_meta = [
285 'metatitle' => get_post_meta($post_id, '_metasync_metatitle', true),
286 'metadesc' => get_post_meta($post_id, '_metasync_metadesc', true),
287 'focus_keyword' => get_post_meta($post_id, '_metasync_focus_keyword', true),
288 'robots_index' => get_post_meta($post_id, '_metasync_robots_index', true),
289 'canonical_url' => get_post_meta($post_id, '_metasync_canonical_url', true)
290 ];
291
292 // Get Open Graph meta
293 $opengraph_meta = [
294 'og_enabled' => get_post_meta($post_id, '_metasync_og_enabled', true),
295 'og_title' => get_post_meta($post_id, '_metasync_og_title', true),
296 'og_description' => get_post_meta($post_id, '_metasync_og_description', true),
297 'og_image' => get_post_meta($post_id, '_metasync_og_image', true),
298 'og_url' => get_post_meta($post_id, '_metasync_og_url', true),
299 'og_type' => get_post_meta($post_id, '_metasync_og_type', true)
300 ];
301
302 // Get Twitter Card meta
303 $twitter_meta = [
304 'twitter_card' => get_post_meta($post_id, '_metasync_twitter_card', true),
305 'twitter_title' => get_post_meta($post_id, '_metasync_twitter_title', true),
306 'twitter_description' => get_post_meta($post_id, '_metasync_twitter_description', true),
307 ];
308
309 return $this->success([
310 'post_id' => $post_id,
311 'post_title' => $post->post_title,
312 'post_type' => $post->post_type,
313 'post_status' => $post->post_status,
314 'seo_meta' => $seo_meta,
315 'opengraph_meta' => $opengraph_meta,
316 'twitter_meta' => $twitter_meta
317 ]);
318 }
319 }
320 }
321
322 /**
323 * Get SEO Meta Tool
324 */
325 class MCP_Tool_Get_SEO_Meta extends MCP_Tool_Base {
326
327 public function get_name() {
328 return 'wordpress_get_seo_meta';
329 }
330
331 public function get_description() {
332 return 'Get all SEO-related metadata for a post including title, description, keywords, indexing settings, and Open Graph/social meta';
333 }
334
335 public function get_input_schema() {
336 return [
337 'type' => 'object',
338 'properties' => [
339 'post_id' => [
340 'type' => 'integer',
341 'description' => 'WordPress post or page ID',
342 'minimum' => 1
343 ]
344 ],
345 'required' => ['post_id']
346 ];
347 }
348
349 public function execute($params) {
350 $this->validate_params($params);
351 $this->require_capability('read');
352
353 $post_id = $this->sanitize_integer($params['post_id']);
354
355 // Verify post exists
356 $post = $this->verify_post_exists($post_id);
357
358 // Get all SEO meta
359 $seo_data = [
360 'post_info' => [
361 'id' => $post_id,
362 'title' => $post->post_title,
363 'type' => $post->post_type,
364 'status' => $post->post_status,
365 'url' => get_permalink($post_id)
366 ],
367 'seo_meta' => [
368 'meta_title' => get_post_meta($post_id, '_metasync_metatitle', true),
369 'meta_description' => get_post_meta($post_id, '_metasync_metadesc', true),
370 'focus_keyword' => get_post_meta($post_id, '_metasync_focus_keyword', true),
371 'robots_index' => get_post_meta($post_id, '_metasync_robots_index', true),
372 'canonical_url' => get_post_meta($post_id, '_metasync_canonical_url', true),
373 'robots_advanced' => json_decode(get_post_meta($post_id, '_metasync_robots_advanced', true) ?: '{}', true),
374 'primary_category' => $this->get_primary_category_data($post_id),
375 ],
376 'opengraph_meta' => [
377 'enabled' => get_post_meta($post_id, '_metasync_og_enabled', true),
378 'title' => get_post_meta($post_id, '_metasync_og_title', true),
379 'description' => get_post_meta($post_id, '_metasync_og_description', true),
380 'image' => get_post_meta($post_id, '_metasync_og_image', true),
381 'url' => get_post_meta($post_id, '_metasync_og_url', true),
382 'type' => get_post_meta($post_id, '_metasync_og_type', true)
383 ],
384 'twitter_meta' => [
385 'twitter_card' => get_post_meta($post_id, '_metasync_twitter_card', true),
386 'twitter_title' => get_post_meta($post_id, '_metasync_twitter_title', true),
387 'twitter_description' => get_post_meta($post_id, '_metasync_twitter_description', true),
388 ],
389 'analysis' => [
390 'meta_title_length' => mb_strlen(get_post_meta($post_id, '_metasync_metatitle', true)),
391 'meta_desc_length' => mb_strlen(get_post_meta($post_id, '_metasync_metadesc', true)),
392 'has_focus_keyword' => !empty(get_post_meta($post_id, '_metasync_focus_keyword', true)),
393 'is_indexable' => get_post_meta($post_id, '_metasync_robots_index', true) !== 'noindex',
394 'og_enabled' => get_post_meta($post_id, '_metasync_og_enabled', true) === '1',
395 'has_og_image' => !empty(get_post_meta($post_id, '_metasync_og_image', true)),
396 'has_robots_advanced' => !empty(get_post_meta($post_id, '_metasync_robots_advanced', true))
397 ]
398 ];
399
400 return $this->success($seo_data);
401 }
402
403 /**
404 * Get primary category data for a post
405 *
406 * @param int $post_id The post ID
407 * @return array|null Primary category data or null if not set
408 */
409 private function get_primary_category_data($post_id) {
410 $primary_cat_id = (int) get_post_meta($post_id, '_metasync_primary_category', true);
411
412 if ($primary_cat_id === 0) {
413 return null;
414 }
415
416 $term = get_term($primary_cat_id, 'category');
417 if (!$term || is_wp_error($term)) {
418 return null;
419 }
420
421 return [
422 'term_id' => $primary_cat_id,
423 'name' => $term->name,
424 'slug' => $term->slug,
425 ];
426 }
427 }
428
429 /**
430 * Get Hreflang Links Tool
431 *
432 * Returns all hreflang entries for a post (manual + WPML auto-detected),
433 * validates that each referenced URL returns HTTP 200 and links back to
434 * this post (hreflang reciprocity), and flags a missing x-default entry
435 * and a missing self-reference.
436 */
437 class MCP_Tool_Get_Hreflang_Links extends MCP_Tool_Base {
438
439 public function get_name() {
440 return 'wordpress_get_hreflang_links';
441 }
442
443 public function get_description() {
444 return 'Get all hreflang entries for a post (manual + WPML auto-detected), validate each URL returns HTTP 200 and links back to this post (reciprocity), and flag missing x-default / self-reference entries';
445 }
446
447 public function get_input_schema() {
448 return [
449 'type' => 'object',
450 'properties' => [
451 'post_id' => [
452 'type' => 'integer',
453 'description' => 'WordPress post or page ID',
454 'minimum' => 1
455 ]
456 ],
457 'required' => ['post_id']
458 ];
459 }
460
461 public function execute($params) {
462 $this->validate_params($params);
463 $this->require_capability('read');
464
465 $post_id = $this->sanitize_integer($params['post_id']);
466 $post = $this->verify_post_exists($post_id);
467 $this->check_post_permission($post_id);
468
469 $manual_entries = $this->get_manual_entries($post_id);
470 $wpml_entries = $this->get_wpml_entries($post_id, $post);
471
472 // Merge: auto-detected first, then manual entries override by the
473 // normalised language-code collision key. Rows with an invalid code
474 // (empty key) are kept verbatim instead of collapsing into one —
475 // the audit report is where they become visible.
476 $by_key = [];
477 $invalid = [];
478 foreach ($wpml_entries as $entry) {
479 $key = Metasync_Hreflang_Output::lang_code($entry);
480 if ($key === '') {
481 $invalid[] = $entry;
482 } else {
483 $by_key[$key] = $entry;
484 }
485 }
486 foreach ($manual_entries as $entry) {
487 $key = Metasync_Hreflang_Output::lang_code($entry);
488 if ($key === '') {
489 $invalid[] = $entry;
490 } else {
491 $by_key[$key] = $entry;
492 }
493 }
494 $entries = array_merge(array_values($by_key), $invalid);
495
496 $self_url = get_permalink($post_id);
497
498 // Validate each URL: HTTP 200 (HEAD first, GET on 405) and — when
499 // the target is an external alternate — a return link to this post
500 // (hreflang must be bidirectional; Google ignores one-way clusters).
501 $has_x_default = false;
502 $has_self_reference = false;
503 foreach ($entries as &$entry) {
504 $url = isset($entry['url']) ? $entry['url'] : '';
505 $status = null;
506 $error = null;
507 $return_link = null;
508
509 if (!empty($url)) {
510 $response = wp_remote_head($url, ['timeout' => 5, 'sslverify' => false]);
511 if (is_wp_error($response)) {
512 $error = $response->get_error_message();
513 } else {
514 $status = (int) wp_remote_retrieve_response_code($response);
515 if ($status === 405) {
516 $response = wp_remote_get($url, ['timeout' => 5, 'sslverify' => false]);
517 if (is_wp_error($response)) {
518 $error = $response->get_error_message();
519 $status = null;
520 } else {
521 $status = (int) wp_remote_retrieve_response_code($response);
522 }
523 }
524 }
525
526 // Reciprocity: fetch the alternate page and check whether
527 // its hreflang set contains this post's URL.
528 if ($error === null && $status === 200 && !empty($self_url) && $url !== $self_url) {
529 $response = wp_remote_get($url, ['timeout' => 5, 'sslverify' => false]);
530 if (!is_wp_error($response)) {
531 $body = (string) wp_remote_retrieve_body($response);
532 $return_link = self::body_links_back($body, $self_url);
533 }
534 }
535 }
536
537 $entry['http_status'] = $status;
538 $entry['http_ok'] = ($status === 200);
539 if ($error !== null) {
540 $entry['http_error'] = $error;
541 }
542 if ($return_link !== null) {
543 $entry['return_link'] = $return_link;
544 }
545
546 if (Metasync_Hreflang_Output::lang_code($entry) === 'x-default') {
547 $has_x_default = true;
548 }
549 if (!empty($self_url) && $url === $self_url) {
550 $has_self_reference = true;
551 }
552 }
553 unset($entry);
554
555 return $this->success([
556 'post_id' => $post_id,
557 'post_title' => $post->post_title,
558 'self_url' => $self_url,
559 'entries' => $entries,
560 'missing_x_default' => !$has_x_default,
561 'missing_self_reference' => !$has_self_reference,
562 ]);
563 }
564
565 /**
566 * True when the page body contains a hreflang link pointing back at the
567 * given URL.
568 *
569 * @param string $body HTML body of the alternate page.
570 * @param string $self_url This post's permalink.
571 * @return bool
572 */
573 private static function body_links_back($body, $self_url) {
574 if (!preg_match_all('/<link[^>]+hreflang\s*=\s*[\'"]([^\'"]*)[\'"][^>]*>/i', (string) $body, $links)) {
575 return false;
576 }
577 foreach ($links[0] as $tag) {
578 if (stripos($tag, 'rel="alternate"') === false && stripos($tag, "rel='alternate'") === false) {
579 continue;
580 }
581 if (strpos($tag, $self_url) !== false) {
582 return true;
583 }
584 }
585 return false;
586 }
587
588 /**
589 * Read manual hreflang entries from `_metasync_hreflang` post meta.
590 *
591 * Rows are normalised and carry their final lang_code plus a valid flag,
592 * so an invalid stored row is visible in the report (the emitter skips
593 * it silently — this is the tool that surfaces it).
594 *
595 * @param int $post_id Post ID.
596 * @return array
597 */
598 private function get_manual_entries($post_id) {
599 $raw = get_post_meta($post_id, '_metasync_hreflang', true);
600 if (empty($raw) || !is_string($raw)) {
601 return [];
602 }
603 $decoded = json_decode($raw, true);
604 if (!is_array($decoded)) {
605 return [];
606 }
607 $entries = [];
608 foreach ($decoded as $entry) {
609 if (!is_array($entry)) {
610 continue;
611 }
612 $normalized = Metasync_Hreflang_Output::normalize_entry($entry);
613 $entries[] = [
614 'lang' => $normalized['lang'],
615 'region' => $normalized['region'],
616 'url' => $normalized['url'],
617 'lang_code' => $normalized['lang_code'],
618 'valid' => $normalized['valid'],
619 'source' => 'manual',
620 ];
621 }
622 return $entries;
623 }
624
625 /**
626 * Build WPML auto-detected entries via the shared implementation in
627 * Metasync_Hreflang_Output::get_wpml_entries() (publish-only rows,
628 * x-default synthesis) so the audit reports exactly what the front end
629 * emits.
630 *
631 * @param int $post_id Post ID.
632 * @param WP_Post $post Post object (already verified).
633 * @return array
634 */
635 private function get_wpml_entries($post_id, $post) {
636 if (!defined('ICL_SITEPRESS_VERSION')) {
637 return [];
638 }
639
640 $entries = (new Metasync_Hreflang_Output())->get_wpml_entries($post_id);
641
642 $out = [];
643 foreach ($entries as $entry) {
644 $normalized = Metasync_Hreflang_Output::normalize_entry($entry);
645 $normalized['source'] = 'wpml';
646 $out[] = $normalized;
647 }
648 return $out;
649 }
650 }
651