PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
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-plugin-settings.php

class-mcp-tool-plugin-settings.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.7.2, at wp-mcp-server/tools/class-mcp-tool-plugin-settings.php

926 lines 36.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * MCP Tool: Plugin Settings Management
4 *
5 * Provides tools for viewing and managing WordPress plugin settings.
6 * This allows AI agents to read and update all MetaSync plugin configuration.
7 *
8 * @package MetaSync
9 * @subpackage MCP_Server/Tools
10 */
11
12 if (!defined('ABSPATH')) {
13 exit;
14 }
15
16 /**
17 * White-label guard for the MCP plugin-settings tools.
18 *
19 * The white-label workflow is agency → client: the agency sets branding,
20 * locks it with the whitelabel settings password in the admin UI, and ships
21 * the branded plugin to the client via Export-as-ZIP. That password only
22 * gates the admin UI tabs (handle_session_management_early()); it does not
23 * guard the MCP path. Exposing white-label settings through MCP would let
24 * any MCP credential holder (e.g. the client) re-brand the plugin or unset
25 * the password, unlocking the guarded UI tabs and bypassing the agency's
26 * lock entirely.
27 *
28 * White-label settings are therefore hidden from MCP reads and rejected on
29 * MCP writes. They remain manageable only through the password-gated admin
30 * UI and the whitelabel-settings.json import/export flow.
31 */
32 final class MCP_Plugin_Settings_Whitelabel_Guard {
33
34 /**
35 * general.* option keys that carry white-label branding.
36 *
37 * Fallback only — when the plugin classes are loaded (production and
38 * most test paths), general_branding_keys() delegates to
39 * Metasync_Whitelabel_Preservation::general_whitelabel_keys() so this
40 * list can never drift from the reset/export source of truth.
41 */
42 private static $general_branding_keys_fallback = [
43 'white_label_plugin_name',
44 'white_label_plugin_description',
45 'white_label_plugin_author',
46 'white_label_plugin_author_uri',
47 'white_label_plugin_uri',
48 'white_label_plugin_menu_slug',
49 'white_label_plugin_menu_icon',
50 'whitelabel_otto_name',
51 ];
52
53 /**
54 * Extra legacy flat keys (beyond the general branding keys) that carry
55 * white-label data, including the settings password itself.
56 */
57 private static $extra_flat_protected_keys = [
58 'whitelabel_logo_url',
59 'whitelabel_domain_url',
60 'whitelabel_settings_password',
61 ];
62
63 /**
64 * Return the general.* keys that carry white-label branding, from the
65 * single source of truth when it is available.
66 *
67 * @return string[]
68 */
69 public static function general_branding_keys() {
70 if (class_exists('Metasync_Whitelabel_Preservation')) {
71 return Metasync_Whitelabel_Preservation::general_whitelabel_keys();
72 }
73 return self::$general_branding_keys_fallback;
74 }
75
76 /**
77 * Return every flat top-level key that must not leave the server via MCP.
78 *
79 * @return string[]
80 */
81 public static function flat_protected_keys() {
82 return array_merge(self::general_branding_keys(), self::$extra_flat_protected_keys);
83 }
84
85 /**
86 * Remove every white-label value from an option tree before it is
87 * returned to an MCP caller. Covers the nested whitelabel blob, the
88 * general branding keys, and any legacy flat keys.
89 *
90 * @param mixed $options
91 * @return mixed
92 */
93 public static function strip_from_options($options) {
94 if (!is_array($options)) {
95 return $options;
96 }
97 unset($options['whitelabel']);
98 if (isset($options['general']) && is_array($options['general'])) {
99 foreach (self::general_branding_keys() as $key) {
100 unset($options['general'][$key]);
101 }
102 }
103 foreach (self::flat_protected_keys() as $key) {
104 unset($options[$key]);
105 }
106 return $options;
107 }
108
109 /**
110 * Throw when resolved settings address any white-label path — the whole
111 * whitelabel section, a branding key nested under general, or a legacy
112 * flat key.
113 *
114 * Keys are compared in their sanitize_key()-normalized form. The guard
115 * runs BEFORE sanitize_settings(), but the store is written with
116 * post-sanitization keys, so a caller sending "Whitelabel",
117 * "White_Label_Plugin_Name", or "whitelabel\u0000" would otherwise sail
118 * past an exact match here and land on the protected path when
119 * sanitize_key() lowercases and strips the noise characters at write
120 * time.
121 *
122 * @param array $settings Resolved settings (post alias resolution).
123 * @return void
124 * @throws Exception When a white-label setting is addressed.
125 */
126 public static function assert_not_addressed($settings) {
127 if (!is_array($settings)) {
128 return;
129 }
130 foreach ($settings as $key => $value) {
131 $norm = sanitize_key((string) $key);
132 if ($norm === 'whitelabel') {
133 self::reject('whitelabel.*');
134 }
135 if (in_array($norm, self::flat_protected_keys(), true)) {
136 self::reject($norm);
137 }
138 if ($norm === 'general' && is_array($value)) {
139 foreach ($value as $general_key => $_) {
140 $general_norm = sanitize_key((string) $general_key);
141 if (in_array($general_norm, self::general_branding_keys(), true)) {
142 self::reject('general.' . $general_norm);
143 }
144 }
145 }
146 }
147 }
148
149 /**
150 * @param string $key
151 * @return void
152 * @throws Exception
153 */
154 private static function reject($key) {
155 throw new Exception(
156 'Setting "' . esc_html($key) . '" is not available through MCP: '
157 . 'white-label branding is managed only in the plugin admin UI '
158 . '(password-gated) or via the whitelabel-settings.json import/export flow.'
159 );
160 }
161 }
162
163 /**
164 * Get Plugin Settings Tool
165 */
166 class MCP_Tool_Get_Plugin_Settings extends MCP_Tool_Base {
167
168 public function get_name() {
169 return 'wordpress_get_plugin_settings';
170 }
171
172 public function get_description() {
173 return 'Get all plugin settings or settings for a specific section. Returns the plugin configuration including features, SEO controls, API keys, and more. White-label settings are not available through this tool (manageable only in the password-gated admin UI).';
174 }
175
176 public function get_input_schema() {
177 return [
178 'type' => 'object',
179 'properties' => [
180 'section' => [
181 'type' => 'string',
182 'description' => 'Optional: specific settings section to retrieve (e.g., "general", "seo", "social"). If omitted, returns all settings.',
183 'enum' => [
184 'general',
185 'seo',
186 'social',
187 'advanced',
188 'features',
189 'api',
190 'all'
191 ]
192 ],
193 'keys' => [
194 'type' => 'array',
195 'description' => 'Optional: specific setting keys to retrieve. If provided, only these keys will be returned.',
196 'items' => [
197 'type' => 'string'
198 ]
199 ]
200 ],
201 'required' => []
202 ];
203 }
204
205 public function execute($params) {
206 // Validate and check permissions
207 $this->validate_params($params);
208 $this->require_capability('manage_options');
209
210 // Get all plugin options. White-label settings (branding and the
211 // whitelabel settings password) are stripped before the values leave
212 // the server — they are managed only through the password-gated admin
213 // UI and the whitelabel-settings.json import/export flow.
214 $all_options = MCP_Plugin_Settings_Whitelabel_Guard::strip_from_options(
215 get_option(Metasync::option_name, [])
216 );
217
218 // If specific keys requested
219 if (!empty($params['keys']) && is_array($params['keys'])) {
220 $result = [];
221 foreach ($params['keys'] as $key) {
222 $key = $this->sanitize_string($key);
223 if (isset($all_options[$key])) {
224 $result[$key] = $all_options[$key];
225 }
226 }
227 return $this->success([
228 'settings' => $this->mask_sensitive_values($result),
229 'count' => count($result)
230 ]);
231 }
232
233 // If section specified (options are nested e.g. $all_options['general']['apikey'])
234 if (!empty($params['section']) && $params['section'] !== 'all') {
235 $section = $this->sanitize_string($params['section']);
236
237 if (isset($all_options[$section]) && is_array($all_options[$section])) {
238 $result = $all_options[$section];
239 } else {
240 // Fallback: map section to flat keys for legacy structure
241 $section_keys = $this->get_section_keys($section);
242 $result = [];
243 foreach ($section_keys as $key) {
244 if (isset($all_options[$key])) {
245 $result[$key] = $all_options[$key];
246 }
247 }
248 }
249
250 return $this->success([
251 'section' => $section,
252 'settings' => $this->mask_sensitive_values($result),
253 'count' => count($result)
254 ]);
255 }
256
257 // Return all settings
258 return $this->success([
259 'settings' => $this->mask_sensitive_values($all_options),
260 'count' => count($all_options),
261 'available_sections' => [
262 'general' => 'API keys, integration settings',
263 'seo' => 'SEO controls and indexation settings',
264 'social' => 'Social media and OpenGraph settings',
265 'advanced' => 'Advanced plugin features',
266 'features' => 'Feature toggles and visibility',
267 'api' => 'API configuration and tokens'
268 ]
269 ]);
270 }
271
272 /**
273 * Recursively mask sensitive values before returning settings to a caller.
274 *
275 * The Search Atlas API key is stored encrypted at rest (enc_v1: ciphertext).
276 * Neither the ciphertext nor the decrypted plaintext may leave the server,
277 * so any 'searchatlas_api_key' entry is replaced with a masked display
278 * (8 bullets + last 4 chars of the real key), or '' when not configured.
279 *
280 * @param mixed $data
281 * @return mixed
282 */
283 private function mask_sensitive_values($data) {
284 if (!is_array($data)) {
285 return $data;
286 }
287 foreach ($data as $key => $value) {
288 if (is_array($value)) {
289 $data[$key] = $this->mask_sensitive_values($value);
290 } elseif ($key === 'searchatlas_api_key') {
291 $decrypted = Metasync::get_searchatlas_api_key();
292 if ($decrypted === false || $decrypted === '') {
293 $data[$key] = '';
294 } else {
295 $data[$key] = str_repeat('•', 8) . substr($decrypted, -4);
296 }
297 }
298 }
299 return $data;
300 }
301
302 /**
303 * Get setting keys for a specific section
304 */
305 private function get_section_keys($section) {
306 $section_map = [
307 'general' => [
308 'searchatlas_api_key',
309 'apikey',
310 'permalink_structure',
311 'hide_dashboard_framework',
312 'show_admin_bar_status',
313 'enable_schema_markup',
314 'default_schema_type'
315 ],
316 'seo' => [
317 'index_date_archives',
318 'index_tag_archives',
319 'index_author_archives',
320 'index_format_archives',
321 'index_category_archives',
322 'override_robots_tags',
323 'enable_googleinstantindex',
324 'google_index_api_config'
325 ],
326 'social' => [
327 'otto_pixel_uuid',
328 'otto_disable_on_loggedin',
329 'otto_disable_preview_button',
330 'periodic_clear_ottopage_cache',
331 'periodic_clear_ottopost_cache',
332 'periodic_clear_otto_cache'
333 ],
334 'advanced' => [
335 'disable_common_robots_metabox',
336 'disable_advance_robots_metabox',
337 'disable_redirection_metabox',
338 'disable_canonical_metabox',
339 'disable_social_opengraph_metabox',
340 'disable_schema_markup_metabox',
341 'disable_seo_metabox'
342 ],
343 'features' => [
344 'enabled_elementor_plugin_css',
345 'enabled_elementor_plugin_css_color',
346 'import_external_data',
347 'content_genius_sync_roles'
348 ],
349 'api' => [
350 'searchatlas_api_key',
351 'apikey',
352 'google_index_api_config'
353 ]
354 ];
355
356 return $section_map[$section] ?? [];
357 }
358 }
359
360 /**
361 * Update Plugin Settings Tool
362 */
363 class MCP_Tool_Update_Plugin_Settings extends MCP_Tool_Base {
364
365 public function get_name() {
366 return 'wordpress_update_plugin_settings';
367 }
368
369 public function get_description() {
370 return 'Update one or more plugin settings. Allows modifying plugin configuration including features, SEO controls, API keys, and more. White-label settings are not available through this tool.';
371 }
372
373 public function get_input_schema() {
374 return [
375 'type' => 'object',
376 'properties' => [
377 'settings' => [
378 'type' => 'object',
379 'description' => 'Key-value pairs of settings to update. Each key is a setting name and value is the new value.',
380 'additionalProperties' => true
381 ],
382 'merge' => [
383 'type' => 'boolean',
384 'description' => 'If true (default), merges with existing settings. If false, replaces all settings with provided values.',
385 'default' => true
386 ]
387 ],
388 'required' => ['settings']
389 ];
390 }
391
392 public function execute($params) {
393 // Validate and check permissions
394 $this->validate_params($params);
395 $this->require_capability('manage_options');
396
397 if (empty($params['settings']) || !is_array($params['settings'])) {
398 throw new Exception('Settings must be a non-empty object/array');
399 }
400
401 $merge = isset($params['merge']) ? (bool)$params['merge'] : true;
402
403 // Resolve any aliased or misnamed keys to their canonical nested paths
404 $settings = $this->resolve_setting_aliases($params['settings']);
405
406 // otto_pixel_uuid is read-only. It is assigned only by the
407 // trusted Search Atlas / heartbeat synchronization flows; a username or
408 // any other arbitrary text stored here breaks OTTO SSR authentication.
409 // Reject the write outright rather than silently dropping it, so an
410 // MCP consumer knows to re-authenticate instead of retrying the write.
411 $this->assert_no_read_only_keys($settings);
412
413 // White-label settings (branding + the whitelabel settings password)
414 // must not be writable through MCP — the password only gates the
415 // admin UI, so an MCP write would bypass the agency's lock. Reject
416 // with an explanatory error rather than silently dropping the write.
417 // Asserted twice: once on the raw payload and once on its sanitized
418 // form, so any key spelling that survives into the store is caught.
419 MCP_Plugin_Settings_Whitelabel_Guard::assert_not_addressed($settings);
420 MCP_Plugin_Settings_Whitelabel_Guard::assert_not_addressed($this->sanitize_settings($settings));
421
422 // Get current options
423 $current_options = get_option(Metasync::option_name, []);
424
425 if ($merge) {
426 // Deep merge to preserve nested sub-arrays (e.g. general, seo_controls)
427 $new_options = $this->array_merge_deep($current_options, $settings);
428 } else {
429 // Replace all settings
430 $new_options = $settings;
431 }
432
433 // White-label branding values are mirrored into the metasync.php
434 // plugin header; a value that fails the header contract would be
435 // stored but could never be applied, leaving the options inconsistent
436 // with the Plugins screen branding. Reject the whole write with the
437 // offending fields named — the same contract the administrator form
438 // and the JSON package import enforce. Only keys this write actually
439 // supplies are checked, so legacy stored values never block unrelated
440 // settings updates.
441 if (!class_exists('Metasync_Activator')) {
442 require_once dirname(__DIR__, 2) . '/includes/class-metasync-activator.php';
443 }
444 $submitted_general = is_array($settings['general'] ?? null) ? $settings['general'] : [];
445 $submitted_branding = array_intersect_key(
446 $submitted_general,
447 array_flip(Metasync_Activator::BRANDING_HEADER_FIELDS)
448 );
449 $branding_errors = Metasync_Activator::validate_plugin_header_settings($submitted_branding);
450 if (!empty($branding_errors)) {
451 // The messages are plugin-controlled static labels; the escaping
452 // layer is inert here, but Plugin Check's EscapeOutput sniff
453 // requires an escaper on dynamic output inside an exception
454 // message, so keep it.
455 throw new Exception(
456 'Invalid white-label branding values; nothing was saved: '
457 . esc_html(implode(' ', array_values($branding_errors)))
458 );
459 }
460
461 // Replace mode can omit the read-only UUID, but omission must not
462 // disconnect the site. Always carry the stored value forward.
463 $stored_uuid_present = isset($current_options['general'])
464 && is_array($current_options['general'])
465 && array_key_exists('otto_pixel_uuid', $current_options['general']);
466 if ($stored_uuid_present) {
467 if (!isset($new_options['general']) || !is_array($new_options['general'])) {
468 $new_options['general'] = [];
469 }
470 $new_options['general']['otto_pixel_uuid'] = $current_options['general']['otto_pixel_uuid'];
471 }
472
473 // Sanitize sensitive fields
474 $new_options = $this->sanitize_settings($new_options);
475 if ($stored_uuid_present) {
476 $new_options['general']['otto_pixel_uuid'] = $current_options['general']['otto_pixel_uuid'];
477 }
478
479 // Replace mode can omit the whole whitelabel identity, but omission
480 // must not be able to destroy it either. Carry the stored whitelabel
481 // blob and general branding keys forward untouched (post-sanitize,
482 // like the UUID above, so stored values are not re-sanitized).
483 if (isset($current_options['whitelabel']) && is_array($current_options['whitelabel'])) {
484 if (!isset($new_options['whitelabel']) || !is_array($new_options['whitelabel'])) {
485 $new_options['whitelabel'] = [];
486 }
487 $new_options['whitelabel'] = array_merge($new_options['whitelabel'], $current_options['whitelabel']);
488 }
489 if (isset($current_options['general']) && is_array($current_options['general'])) {
490 foreach (MCP_Plugin_Settings_Whitelabel_Guard::general_branding_keys() as $key) {
491 if (array_key_exists($key, $current_options['general'])) {
492 if (!isset($new_options['general']) || !is_array($new_options['general'])) {
493 $new_options['general'] = [];
494 }
495 $new_options['general'][$key] = $current_options['general'][$key];
496 }
497 }
498 }
499
500 // Encrypt the Search Atlas API key at rest if this update supplied a new
501 // value. It is persisted as enc_v1: ciphertext so the cleartext never
502 // lands in wp_options. Already-encrypted values pass through untouched.
503 if (isset($new_options['general']['searchatlas_api_key'])
504 && !Metasync::is_encrypted_api_key($new_options['general']['searchatlas_api_key'])) {
505 $new_options['general']['searchatlas_api_key'] =
506 Metasync::encrypt_api_key($new_options['general']['searchatlas_api_key']);
507 }
508 if (isset($new_options['searchatlas_api_key'])
509 && !Metasync::is_encrypted_api_key($new_options['searchatlas_api_key'])) {
510 $new_options['searchatlas_api_key'] =
511 Metasync::encrypt_api_key($new_options['searchatlas_api_key']);
512 }
513
514 // Defense in depth only: with the whitelabel guard working, the
515 // carried-forward value is always the already-encrypted stored one,
516 // so encrypt_secret() is a no-op here. This can only do real work
517 // for a legacy plaintext-at-rest value; MCP callers cannot write
518 // this field (the guard above rejects every whitelabel path).
519 if (!empty($new_options['whitelabel']['settings_password']) && is_string($new_options['whitelabel']['settings_password'])) {
520 $new_options['whitelabel']['settings_password'] = Metasync::encrypt_secret($new_options['whitelabel']['settings_password']);
521 }
522
523 // Update the option
524 $updated = update_option(Metasync::option_name, $new_options);
525
526 if ($updated === false && $current_options !== $new_options) {
527 throw new Exception('Failed to update plugin settings');
528 }
529
530 // Clear any relevant caches
531 wp_cache_delete(Metasync::option_name, 'options');
532 Metasync::invalidate_api_key_cache();
533
534 return $this->success([
535 'message' => 'Plugin settings updated successfully',
536 'updated_keys' => array_keys($params['settings']),
537 'merge_mode' => $merge,
538 'total_settings' => count($new_options)
539 ]);
540 }
541
542 /**
543 * Resolve aliased or flat keys to their canonical nested location.
544 *
545 * When an MCP consumer sends a flat key like "enable_schema_markup", this
546 * method routes it into the correct nested sub-array ("general") so it
547 * updates the same option the admin UI uses.
548 */
549 private function resolve_setting_aliases($settings) {
550 // Map of flat keys → [section, canonical_key]
551 $nested_key_map = [
552 'enable_schema_markup' => ['general', 'enable_schema_markup'],
553 'enable_schema' => ['general', 'enable_schema_markup'],
554 'default_schema_type' => ['general', 'default_schema_type'],
555 ];
556
557 $resolved = [];
558 foreach ($settings as $key => $value) {
559 if (isset($nested_key_map[$key])) {
560 list($section, $canonical) = $nested_key_map[$key];
561 if (!isset($resolved[$section])) {
562 $resolved[$section] = [];
563 }
564 $resolved[$section][$canonical] = $value;
565 } else {
566 $resolved[$key] = $value;
567 }
568 }
569
570 return $resolved;
571 }
572
573 /**
574 * Settings this tool refuses to write because another, trusted flow owns
575 * them.
576 *
577 * general.otto_pixel_uuid is assigned by the Search Atlas SSO callback and
578 * the heartbeat synchronization only. Storing anything else there — a
579 * WordPress username, or arbitrary text an agent guessed — breaks OTTO SSR
580 * authentication, so the write is rejected with an explanatory error
581 * rather than silently dropped.
582 */
583 private function get_read_only_setting_paths() {
584 return [
585 'otto_pixel_uuid' => 'general.otto_pixel_uuid',
586 'general.otto_pixel_uuid' => 'general.otto_pixel_uuid',
587 // The schema groups the UUID under "social"; a consumer following
588 // that documentation must get the same rejection rather than a
589 // silent write into a bogus top-level "social" section.
590 'social.otto_pixel_uuid' => 'social.otto_pixel_uuid',
591 ];
592 }
593
594 /**
595 * Throw when the requested settings touch a read-only path.
596 *
597 * @param array $settings Resolved settings (post alias resolution).
598 * @return void
599 * @throws Exception When a read-only setting is addressed.
600 */
601 private function assert_no_read_only_keys($settings) {
602 foreach ($this->get_read_only_setting_paths() as $needle => $canonical) {
603 list($section, $key) = explode('.', $canonical);
604 $addressed = array_key_exists($needle, $settings)
605 || (isset($settings[$section]) && is_array($settings[$section]) && array_key_exists($key, $settings[$section]));
606 if ($addressed) {
607 $canonical_display = ($canonical === 'social.otto_pixel_uuid')
608 ? 'general.otto_pixel_uuid'
609 : $canonical;
610 throw new Exception(
611 'Setting "' . esc_html($canonical_display) . '" is read-only: the '
612 . 'OTTO Pixel UUID is assigned automatically by the Search Atlas / heartbeat '
613 . 'synchronization flows and cannot be set through this tool. '
614 . 'Use the plugin\'s one-click authentication to change it.'
615 );
616 }
617 }
618 }
619
620 /**
621 * Recursively merge two arrays, preserving nested sub-arrays.
622 */
623 private function array_merge_deep($base, $override) {
624 foreach ($override as $key => $value) {
625 if (is_array($value) && isset($base[$key]) && is_array($base[$key])) {
626 $base[$key] = $this->array_merge_deep($base[$key], $value);
627 } else {
628 $base[$key] = $value;
629 }
630 }
631 return $base;
632 }
633
634 /**
635 * Sanitize settings before saving
636 */
637 private function sanitize_settings($settings) {
638 $sanitized = [];
639
640 foreach ($settings as $key => $value) {
641 $key = sanitize_key($key);
642
643 // Handle different types of values
644 if (is_array($value)) {
645 $sanitized[$key] = $this->sanitize_array($value);
646 } elseif (is_bool($value)) {
647 $sanitized[$key] = (bool)$value;
648 } elseif (is_numeric($value)) {
649 $sanitized[$key] = $value;
650 } elseif ($this->is_sensitive_field($key)) {
651 // Don't sanitize sensitive fields too aggressively
652 $sanitized[$key] = $value;
653 } elseif ($this->is_url_field($key)) {
654 $sanitized[$key] = esc_url_raw($value);
655 } else {
656 $sanitized[$key] = sanitize_text_field($value);
657 }
658 }
659
660 return $sanitized;
661 }
662
663 /**
664 * Sanitize array values recursively
665 */
666 private function sanitize_array($array) {
667 $sanitized = [];
668 foreach ($array as $key => $value) {
669 $key = sanitize_key($key);
670 if (is_array($value)) {
671 $sanitized[$key] = $this->sanitize_array($value);
672 } elseif (is_bool($value)) {
673 $sanitized[$key] = $value;
674 } elseif (is_numeric($value)) {
675 $sanitized[$key] = $value;
676 } else {
677 $sanitized[$key] = sanitize_text_field($value);
678 }
679 }
680 return $sanitized;
681 }
682
683 /**
684 * Check if field is sensitive (API keys, tokens, passwords)
685 */
686 private function is_sensitive_field($key) {
687 $sensitive_patterns = ['api_key', 'apikey', 'token', 'password', 'secret'];
688 foreach ($sensitive_patterns as $pattern) {
689 if (stripos($key, $pattern) !== false) {
690 return true;
691 }
692 }
693 return false;
694 }
695
696 /**
697 * Check if field should be a URL
698 */
699 private function is_url_field($key) {
700 $url_patterns = ['url', 'uri', 'domain', 'logo', 'link'];
701 foreach ($url_patterns as $pattern) {
702 if (stripos($key, $pattern) !== false) {
703 return true;
704 }
705 }
706 return false;
707 }
708 }
709
710 /**
711 * List Available Settings Tool
712 */
713 class MCP_Tool_List_Plugin_Settings_Schema extends MCP_Tool_Base {
714
715 public function get_name() {
716 return 'wordpress_list_plugin_settings_schema';
717 }
718
719 public function get_description() {
720 return 'Get a schema of all available plugin settings with descriptions and expected types. Useful for understanding what settings can be configured.';
721 }
722
723 public function get_input_schema() {
724 return [
725 'type' => 'object',
726 'properties' => (object)[],
727 'required' => []
728 ];
729 }
730
731 public function execute($params) {
732 $this->validate_params($params);
733 $this->require_capability('manage_options');
734
735 $schema = [
736 'general' => [
737 'description' => 'General plugin configuration',
738 'settings' => [
739 'searchatlas_api_key' => [
740 'type' => 'string',
741 'description' => 'Search Atlas API key for integration',
742 'sensitive' => true
743 ],
744 'apikey' => [
745 'type' => 'string',
746 'description' => 'Plugin authentication token',
747 'sensitive' => true
748 ],
749 'hide_dashboard_framework' => [
750 'type' => 'boolean',
751 'description' => 'Hide the dashboard framework'
752 ],
753 'show_admin_bar_status' => [
754 'type' => 'boolean',
755 'description' => 'Show plugin status in admin bar'
756 ],
757 'enable_schema_markup' => [
758 'type' => 'boolean',
759 'description' => 'Enable automatic schema markup generation. Canonical key — do NOT use "enable_schema".'
760 ],
761 'default_schema_type' => [
762 'type' => 'string',
763 'description' => 'Default schema type for new posts (e.g., "article", "WebSite")'
764 ]
765 ]
766 ],
767 'seo' => [
768 'description' => 'SEO controls and indexation settings',
769 'settings' => [
770 'index_date_archives' => [
771 'type' => 'boolean',
772 'description' => 'Disallow date archives from indexation'
773 ],
774 'index_tag_archives' => [
775 'type' => 'boolean',
776 'description' => 'Disallow tag archives from indexation'
777 ],
778 'index_author_archives' => [
779 'type' => 'boolean',
780 'description' => 'Disallow author archives from indexation'
781 ],
782 'index_format_archives' => [
783 'type' => 'boolean',
784 'description' => 'Disallow format archives from indexation'
785 ],
786 'index_category_archives' => [
787 'type' => 'boolean',
788 'description' => 'Disallow category archives from indexation'
789 ],
790 'override_robots_tags' => [
791 'type' => 'boolean',
792 'description' => 'Override robots tags from other plugins'
793 ],
794 'enable_googleinstantindex' => [
795 'type' => 'boolean',
796 'description' => 'Enable Google Instant Indexing'
797 ]
798 ]
799 ],
800 'social' => [
801 'description' => 'Social media and Otto settings',
802 'settings' => [
803 'otto_pixel_uuid' => [
804 'type' => 'string',
805 'description' => 'Otto Pixel UUID for tracking (READ-ONLY: assigned only by the Search Atlas / heartbeat synchronization; not writable via this tool)',
806 'read_only' => true
807 ],
808 'otto_disable_on_loggedin' => [
809 'type' => 'boolean',
810 'description' => 'Disable Otto for logged-in users'
811 ],
812 'otto_disable_preview_button' => [
813 'type' => 'boolean',
814 'description' => 'Disable Otto frontend toolbar'
815 ]
816 ]
817 ],
818 'advanced' => [
819 'description' => 'Advanced meta box visibility controls',
820 'settings' => [
821 'disable_common_robots_metabox' => [
822 'type' => 'boolean',
823 'description' => 'Disable common robots meta box in post editor'
824 ],
825 'disable_advance_robots_metabox' => [
826 'type' => 'boolean',
827 'description' => 'Disable advanced robots meta box in post editor'
828 ],
829 'disable_redirection_metabox' => [
830 'type' => 'boolean',
831 'description' => 'Disable redirection meta box in post editor'
832 ],
833 'disable_canonical_metabox' => [
834 'type' => 'boolean',
835 'description' => 'Disable canonical meta box in post editor'
836 ],
837 'disable_social_opengraph_metabox' => [
838 'type' => 'boolean',
839 'description' => 'Disable social/OpenGraph meta box in post editor'
840 ],
841 'disable_schema_markup_metabox' => [
842 'type' => 'boolean',
843 'description' => 'Disable schema markup meta box in post editor'
844 ],
845 'disable_seo_metabox' => [
846 'type' => 'boolean',
847 'description' => 'Disable SEO Title & Meta Description meta box in Classic editor'
848 ]
849 ]
850 ],
851 'mcp' => [
852 'description' => 'MCP Server configuration',
853 'settings' => [
854 'metasync_mcp_enabled' => [
855 'type' => 'boolean',
856 'description' => 'Enable/disable the MCP server',
857 'stored_separately' => true,
858 'option_name' => 'metasync_mcp_enabled'
859 ],
860 'metasync_mcp_api_key' => [
861 'type' => 'string',
862 'description' => 'MCP server API key for authentication',
863 'sensitive' => true,
864 'stored_separately' => true,
865 'option_name' => 'metasync_mcp_api_key'
866 ]
867 ]
868 ]
869 ];
870
871 return $this->success([
872 'schema' => $schema,
873 'sections' => array_keys($schema),
874 'total_sections' => count($schema),
875 'note' => 'Some settings like MCP configuration are stored as separate options, not in the main metasync_options array. White-label branding settings are excluded from MCP entirely and are manageable only in the password-gated admin UI or via the whitelabel-settings.json import/export flow.'
876 ]);
877 }
878 }
879
880 /**
881 * Get MCP Server Settings Tool
882 */
883 class MCP_Tool_Get_MCP_Settings extends MCP_Tool_Base {
884
885 public function get_name() {
886 return 'wordpress_get_mcp_settings';
887 }
888
889 public function get_description() {
890 return 'Get MCP server-specific settings including authentication information and endpoint URLs.';
891 }
892
893 public function get_input_schema() {
894 return [
895 'type' => 'object',
896 'properties' => (object)[],
897 'required' => []
898 ];
899 }
900
901 public function execute($params) {
902 $this->validate_params($params);
903 $this->require_capability('manage_options');
904
905 $options = get_option('metasync_options', []);
906 $plugin_auth_token = isset($options['general']['apikey']) ? $options['general']['apikey'] : '';
907
908 return $this->success([
909 'mcp_enabled' => true, // MCP is always enabled
910 'authentication' => [
911 'type' => 'plugin_auth_token',
912 'header' => 'X-API-Key',
913 'token_set' => !empty($plugin_auth_token),
914 'token_length' => !empty($plugin_auth_token) ? strlen($plugin_auth_token) : 0,
915 'token_preview' => !empty($plugin_auth_token) ? substr($plugin_auth_token, 0, 8) . '...' : ''
916 ],
917 'endpoints' => [
918 'rest_endpoint' => rest_url('metasync/v1/mcp'),
919 'health_endpoint' => rest_url('metasync/v1/mcp/health')
920 ],
921 'version' => METASYNC_VERSION
922 ]);
923 }
924 }
925
926