PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.14
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.14
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 2.6.4 2.6.3 All 139 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.14, at wp-mcp-server/tools/class-mcp-tool-post-meta.php

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