PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.19
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.19
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.6.19, at wp-mcp-server/tools/class-mcp-tool-post-meta.php

604 lines 22.7 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 re-encode to canonical form before storing.
86 if ($meta_key === '_metasync_hreflang') {
87 $raw_value = isset($params['meta_value']) ? (string) $params['meta_value'] : '';
88 $decoded = json_decode($raw_value, true);
89 if (!is_array($decoded)) {
90 throw new Exception("_metasync_hreflang must be a JSON array");
91 }
92 foreach ($decoded as $entry) {
93 if (!is_array($entry) || !isset($entry['lang']) || !isset($entry['url'])) {
94 throw new Exception("Each hreflang entry must have 'lang' and 'url' keys");
95 }
96 }
97 // Sanitize each URL to strip javascript: and other unsafe protocols.
98 foreach ($decoded as &$entry) {
99 $entry['url'] = esc_url_raw($entry['url']);
100 }
101 unset($entry);
102 $meta_value = wp_json_encode($decoded);
103 }
104
105 // Validate _metasync_robots_advanced JSON
106 if ($meta_key === '_metasync_robots_advanced') {
107 $raw_value = isset($params['meta_value']) ? (string) $params['meta_value'] : '';
108 $decoded = json_decode($raw_value, true);
109 if (!is_array($decoded)) {
110 throw new Exception("_metasync_robots_advanced must be a JSON object");
111 }
112 $allowed_keys = ['nofollow', 'noarchive', 'nosnippet', 'noimageindex', 'max_snippet', 'max_image_preview', 'max_video_preview'];
113 foreach (array_keys($decoded) as $k) {
114 if (!in_array($k, $allowed_keys, true)) {
115 throw new Exception("Unknown key '{$k}' in _metasync_robots_advanced. Allowed: " . implode(', ', $allowed_keys));
116 }
117 }
118 // Validate types
119 foreach (['nofollow', 'noarchive', 'nosnippet', 'noimageindex'] as $bool_key) {
120 if (isset($decoded[$bool_key]) && !is_bool($decoded[$bool_key])) {
121 $decoded[$bool_key] = (bool) $decoded[$bool_key];
122 }
123 }
124 if (isset($decoded['max_snippet'])) {
125 $decoded['max_snippet'] = (int) $decoded['max_snippet'];
126 }
127 if (isset($decoded['max_image_preview'])) {
128 $valid = ['none', 'standard', 'large'];
129 if (!in_array($decoded['max_image_preview'], $valid, true)) {
130 throw new Exception("max_image_preview must be one of: " . implode(', ', $valid));
131 }
132 }
133 if (isset($decoded['max_video_preview'])) {
134 $decoded['max_video_preview'] = (int) $decoded['max_video_preview'];
135 }
136 $meta_value = wp_json_encode($decoded);
137 }
138
139 // SECURITY: Apply esc_url_raw() to URL-typed meta keys (prevent javascript: protocol).
140 if (in_array($meta_key, ['_metasync_og_image', '_metasync_og_url', '_metasync_canonical_url', '_metasync_og_article_author'])) {
141 $meta_value = esc_url_raw($meta_value);
142 }
143
144 // Canonical must be a usable URL — reject corrupted values like
145 // "Array"/"http://Array" so they can't re-enter storage.
146 if ($meta_key === '_metasync_canonical_url') {
147 $meta_value = Metasync_Canonical_Sanitizer::sanitize_for_save($meta_value);
148 if ($meta_value === '') {
149 return $this->error('canonical_url must be a valid URL');
150 }
151 }
152
153 // Sanitize integer meta fields
154 if ($meta_key === '_metasync_primary_category') {
155 $meta_value = absint($meta_value);
156 }
157
158 // Verify post exists
159 $post = $this->verify_post_exists($post_id);
160
161 // SECURITY: Check user has permission to edit this specific post
162 $this->check_post_permission($post_id);
163
164 // Update meta
165 $current_value = get_post_meta($post_id, $meta_key, true);
166 // Normalize numeric meta keys: WordPress returns stored values as strings,
167 // so cast for type-safe comparison against the integer $meta_value above.
168 if ($meta_key === '_metasync_primary_category') {
169 $current_value = (int) $current_value;
170 }
171 if ($current_value === $meta_value) {
172 // Value already matches — return success without update
173 return $this->success([
174 'post_id' => $post_id,
175 'meta_key' => $meta_key,
176 'meta_value' => $current_value,
177 'updated' => false,
178 'post_title' => $post->post_title,
179 'post_type' => $post->post_type
180 ], 'Meta value already matches, no update needed');
181 }
182 $updated = update_post_meta($post_id, $meta_key, $meta_value);
183
184 if ($updated === false) {
185 throw new Exception("Failed to update meta key '{$meta_key}'");
186 }
187
188 $stored_value = get_post_meta($post_id, $meta_key, true);
189 return $this->success([
190 'post_id' => $post_id,
191 'meta_key' => $meta_key,
192 'meta_value' => $stored_value,
193 'updated' => true,
194 'post_title' => $post->post_title,
195 'post_type' => $post->post_type
196 ], "Meta field '{$meta_key}' updated successfully");
197 }
198 }
199
200 /**
201 * Get Post Meta Tool
202 */
203 class MCP_Tool_Get_Post_Meta extends MCP_Tool_Base {
204
205 public function get_name() {
206 return 'wordpress_get_post_meta';
207 }
208
209 public function get_description() {
210 return 'Get WordPress post meta field value(s)';
211 }
212
213 public function get_input_schema() {
214 return [
215 'type' => 'object',
216 'properties' => [
217 'post_id' => [
218 'type' => 'integer',
219 'description' => 'WordPress post or page ID',
220 'minimum' => 1
221 ],
222 'meta_key' => [
223 'type' => 'string',
224 'description' => 'Specific meta key to retrieve (optional - omit to get all SEO meta)',
225 ]
226 ],
227 'required' => ['post_id']
228 ];
229 }
230
231 public function execute($params) {
232 $this->validate_params($params);
233 $this->require_capability('read');
234
235 $post_id = $this->sanitize_integer($params['post_id']);
236
237 // Verify post exists
238 $post = $this->verify_post_exists($post_id);
239
240 // SECURITY: Check user has permission to read this specific post
241 $this->check_post_permission($post_id);
242
243 // Get meta
244 if (isset($params['meta_key'])) {
245 $meta_key = $this->sanitize_string($params['meta_key']);
246 $meta_value = get_post_meta($post_id, $meta_key, true);
247
248 return $this->success([
249 'post_id' => $post_id,
250 'post_title' => $post->post_title,
251 'meta_key' => $meta_key,
252 'meta_value' => $meta_value
253 ]);
254 } else {
255 // Get all SEO meta
256 $seo_meta = [
257 'metatitle' => get_post_meta($post_id, '_metasync_metatitle', true),
258 'metadesc' => get_post_meta($post_id, '_metasync_metadesc', true),
259 'focus_keyword' => get_post_meta($post_id, '_metasync_focus_keyword', true),
260 'robots_index' => get_post_meta($post_id, '_metasync_robots_index', true),
261 'canonical_url' => get_post_meta($post_id, '_metasync_canonical_url', true)
262 ];
263
264 // Get Open Graph meta
265 $opengraph_meta = [
266 'og_enabled' => get_post_meta($post_id, '_metasync_og_enabled', true),
267 'og_title' => get_post_meta($post_id, '_metasync_og_title', true),
268 'og_description' => get_post_meta($post_id, '_metasync_og_description', true),
269 'og_image' => get_post_meta($post_id, '_metasync_og_image', true),
270 'og_url' => get_post_meta($post_id, '_metasync_og_url', true),
271 'og_type' => get_post_meta($post_id, '_metasync_og_type', true)
272 ];
273
274 // Get Twitter Card meta
275 $twitter_meta = [
276 'twitter_card' => get_post_meta($post_id, '_metasync_twitter_card', true),
277 'twitter_title' => get_post_meta($post_id, '_metasync_twitter_title', true),
278 'twitter_description' => get_post_meta($post_id, '_metasync_twitter_description', true),
279 ];
280
281 return $this->success([
282 'post_id' => $post_id,
283 'post_title' => $post->post_title,
284 'post_type' => $post->post_type,
285 'post_status' => $post->post_status,
286 'seo_meta' => $seo_meta,
287 'opengraph_meta' => $opengraph_meta,
288 'twitter_meta' => $twitter_meta
289 ]);
290 }
291 }
292 }
293
294 /**
295 * Get SEO Meta Tool
296 */
297 class MCP_Tool_Get_SEO_Meta extends MCP_Tool_Base {
298
299 public function get_name() {
300 return 'wordpress_get_seo_meta';
301 }
302
303 public function get_description() {
304 return 'Get all SEO-related metadata for a post including title, description, keywords, indexing settings, and Open Graph/social meta';
305 }
306
307 public function get_input_schema() {
308 return [
309 'type' => 'object',
310 'properties' => [
311 'post_id' => [
312 'type' => 'integer',
313 'description' => 'WordPress post or page ID',
314 'minimum' => 1
315 ]
316 ],
317 'required' => ['post_id']
318 ];
319 }
320
321 public function execute($params) {
322 $this->validate_params($params);
323 $this->require_capability('read');
324
325 $post_id = $this->sanitize_integer($params['post_id']);
326
327 // Verify post exists
328 $post = $this->verify_post_exists($post_id);
329
330 // Get all SEO meta
331 $seo_data = [
332 'post_info' => [
333 'id' => $post_id,
334 'title' => $post->post_title,
335 'type' => $post->post_type,
336 'status' => $post->post_status,
337 'url' => get_permalink($post_id)
338 ],
339 'seo_meta' => [
340 'meta_title' => get_post_meta($post_id, '_metasync_metatitle', true),
341 'meta_description' => get_post_meta($post_id, '_metasync_metadesc', true),
342 'focus_keyword' => get_post_meta($post_id, '_metasync_focus_keyword', true),
343 'robots_index' => get_post_meta($post_id, '_metasync_robots_index', true),
344 'canonical_url' => get_post_meta($post_id, '_metasync_canonical_url', true),
345 'robots_advanced' => json_decode(get_post_meta($post_id, '_metasync_robots_advanced', true) ?: '{}', true),
346 'primary_category' => $this->get_primary_category_data($post_id),
347 ],
348 'opengraph_meta' => [
349 'enabled' => get_post_meta($post_id, '_metasync_og_enabled', true),
350 'title' => get_post_meta($post_id, '_metasync_og_title', true),
351 'description' => get_post_meta($post_id, '_metasync_og_description', true),
352 'image' => get_post_meta($post_id, '_metasync_og_image', true),
353 'url' => get_post_meta($post_id, '_metasync_og_url', true),
354 'type' => get_post_meta($post_id, '_metasync_og_type', true)
355 ],
356 'twitter_meta' => [
357 'twitter_card' => get_post_meta($post_id, '_metasync_twitter_card', true),
358 'twitter_title' => get_post_meta($post_id, '_metasync_twitter_title', true),
359 'twitter_description' => get_post_meta($post_id, '_metasync_twitter_description', true),
360 ],
361 'analysis' => [
362 'meta_title_length' => mb_strlen(get_post_meta($post_id, '_metasync_metatitle', true)),
363 'meta_desc_length' => mb_strlen(get_post_meta($post_id, '_metasync_metadesc', true)),
364 'has_focus_keyword' => !empty(get_post_meta($post_id, '_metasync_focus_keyword', true)),
365 'is_indexable' => get_post_meta($post_id, '_metasync_robots_index', true) !== 'noindex',
366 'og_enabled' => get_post_meta($post_id, '_metasync_og_enabled', true) === '1',
367 'has_og_image' => !empty(get_post_meta($post_id, '_metasync_og_image', true)),
368 'has_robots_advanced' => !empty(get_post_meta($post_id, '_metasync_robots_advanced', true))
369 ]
370 ];
371
372 return $this->success($seo_data);
373 }
374
375 /**
376 * Get primary category data for a post
377 *
378 * @param int $post_id The post ID
379 * @return array|null Primary category data or null if not set
380 */
381 private function get_primary_category_data($post_id) {
382 $primary_cat_id = (int) get_post_meta($post_id, '_metasync_primary_category', true);
383
384 if ($primary_cat_id === 0) {
385 return null;
386 }
387
388 $term = get_term($primary_cat_id, 'category');
389 if (!$term || is_wp_error($term)) {
390 return null;
391 }
392
393 return [
394 'term_id' => $primary_cat_id,
395 'name' => $term->name,
396 'slug' => $term->slug,
397 ];
398 }
399 }
400
401 /**
402 * Get Hreflang Links Tool
403 *
404 * Returns all hreflang entries for a post (manual + WPML auto-detected),
405 * validates that each referenced URL returns HTTP 200, and flags a missing
406 * x-default entry.
407 */
408 class MCP_Tool_Get_Hreflang_Links extends MCP_Tool_Base {
409
410 public function get_name() {
411 return 'wordpress_get_hreflang_links';
412 }
413
414 public function get_description() {
415 return 'Get all hreflang entries for a post (manual + WPML auto-detected), validate each URL returns HTTP 200, and flag a missing x-default entry';
416 }
417
418 public function get_input_schema() {
419 return [
420 'type' => 'object',
421 'properties' => [
422 'post_id' => [
423 'type' => 'integer',
424 'description' => 'WordPress post or page ID',
425 'minimum' => 1
426 ]
427 ],
428 'required' => ['post_id']
429 ];
430 }
431
432 public function execute($params) {
433 $this->validate_params($params);
434 $this->require_capability('read');
435
436 $post_id = $this->sanitize_integer($params['post_id']);
437 $post = $this->verify_post_exists($post_id);
438 $this->check_post_permission($post_id);
439
440 $manual_entries = $this->get_manual_entries($post_id);
441 $wpml_entries = $this->get_wpml_entries($post_id, $post);
442
443 // Merge: auto-detected first, then manual entries override by
444 // lang+region collision key.
445 $by_key = [];
446 foreach ($wpml_entries as $entry) {
447 $by_key[$this->collision_key($entry)] = $entry;
448 }
449 foreach ($manual_entries as $entry) {
450 $by_key[$this->collision_key($entry)] = $entry;
451 }
452 $entries = array_values($by_key);
453
454 // Validate each URL returns HTTP 200 (try HEAD first, fall back to
455 // GET on 405 Method Not Allowed).
456 $has_x_default = false;
457 foreach ($entries as &$entry) {
458 $url = isset($entry['url']) ? $entry['url'] : '';
459 $status = null;
460 $error = null;
461
462 if (!empty($url)) {
463 $response = wp_remote_head($url, ['timeout' => 5, 'sslverify' => false]);
464 if (is_wp_error($response)) {
465 $error = $response->get_error_message();
466 } else {
467 $status = (int) wp_remote_retrieve_response_code($response);
468 if ($status === 405) {
469 $response = wp_remote_get($url, ['timeout' => 5, 'sslverify' => false]);
470 if (is_wp_error($response)) {
471 $error = $response->get_error_message();
472 $status = null;
473 } else {
474 $status = (int) wp_remote_retrieve_response_code($response);
475 }
476 }
477 }
478 }
479
480 $entry['http_status'] = $status;
481 $entry['http_ok'] = ($status === 200);
482 if ($error !== null) {
483 $entry['http_error'] = $error;
484 }
485
486 if (isset($entry['lang']) && $entry['lang'] === 'x-default') {
487 $has_x_default = true;
488 }
489 }
490 unset($entry);
491
492 $missing_x_default = !$has_x_default;
493
494 return $this->success([
495 'post_id' => $post_id,
496 'post_title' => $post->post_title,
497 'entries' => $entries,
498 'missing_x_default' => $missing_x_default,
499 ]);
500 }
501
502 /**
503 * Read manual hreflang entries from `_metasync_hreflang` post meta.
504 *
505 * @param int $post_id Post ID.
506 * @return array
507 */
508 private function get_manual_entries($post_id) {
509 $raw = get_post_meta($post_id, '_metasync_hreflang', true);
510 if (empty($raw)) {
511 return [];
512 }
513 $decoded = json_decode($raw, true);
514 if (!is_array($decoded)) {
515 return [];
516 }
517 $entries = [];
518 foreach ($decoded as $entry) {
519 if (!is_array($entry)) {
520 continue;
521 }
522 $entries[] = [
523 'lang' => isset($entry['lang']) ? (string) $entry['lang'] : '',
524 'region' => isset($entry['region']) ? (string) $entry['region'] : '',
525 'url' => isset($entry['url']) ? (string) $entry['url'] : '',
526 'source' => 'manual',
527 ];
528 }
529 return $entries;
530 }
531
532 /**
533 * Build WPML auto-detected entries by querying the icl_translations table.
534 * Mirrors the logic in Metasync_Hreflang_Output::get_wpml_entries.
535 *
536 * @param int $post_id Post ID.
537 * @param WP_Post $post Post object (already verified).
538 * @return array
539 */
540 private function get_wpml_entries($post_id, $post) {
541 if (!defined('ICL_SITEPRESS_VERSION')) {
542 return [];
543 }
544 global $wpdb;
545 $table = $wpdb->prefix . 'icl_translations';
546 $element_type = 'post_' . $post->post_type;
547
548 $trid = $wpdb->get_var($wpdb->prepare(
549 "SELECT trid FROM {$table} WHERE element_id = %d AND element_type = %s LIMIT 1",
550 $post_id,
551 $element_type
552 ));
553 if (empty($trid)) {
554 return [];
555 }
556
557 $rows = $wpdb->get_results($wpdb->prepare(
558 "SELECT language_code, element_id FROM {$table} WHERE trid = %d",
559 $trid
560 ));
561 if (empty($rows)) {
562 return [];
563 }
564
565 $default_lang = apply_filters('wpml_default_language', null);
566 if (empty($default_lang)) {
567 $default_lang = get_option('wpml_default_language');
568 }
569
570 $entries = [];
571 foreach ($rows as $row) {
572 $permalink = get_permalink((int) $row->element_id);
573 if (empty($permalink)) {
574 continue;
575 }
576 $entries[] = [
577 'lang' => (string) $row->language_code,
578 'region' => '',
579 'url' => $permalink,
580 'source' => 'wpml',
581 ];
582 if (!empty($default_lang) && $row->language_code === $default_lang) {
583 $entries[] = [
584 'lang' => 'x-default',
585 'region' => '',
586 'url' => $permalink,
587 'source' => 'wpml',
588 ];
589 }
590 }
591 return $entries;
592 }
593
594 /**
595 * Collision key in the form `lang-region` (or just `lang` when region
596 * is empty) for de-duplicating entries.
597 */
598 private function collision_key(array $entry) {
599 $lang = isset($entry['lang']) ? (string) $entry['lang'] : '';
600 $region = isset($entry['region']) ? (string) $entry['region'] : '';
601 return $region !== '' ? $lang . '-' . $region : $lang;
602 }
603 }
604