PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.15
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.15
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 / class-metasync-mcp-server.php

class-metasync-mcp-server.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.6.15, at wp-mcp-server/class-metasync-mcp-server.php

742 lines 23.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * MetaSync MCP Server
4 *
5 * Main MCP server class that implements the Model Context Protocol
6 * for WordPress. Exposes WordPress operations as MCP tools.
7 *
8 * @package MetaSync
9 * @subpackage MCP_Server
10 */
11
12 if (!defined('ABSPATH')) {
13 exit;
14 }
15
16 class Metasync_MCP_Server {
17
18 /**
19 * JSON-RPC handler
20 *
21 * @var MCP_JSON_RPC_Handler
22 */
23 private $json_rpc_handler;
24
25 /**
26 * Tool registry
27 *
28 * @var MCP_Tool_Registry
29 */
30 private $tool_registry;
31
32 /**
33 * Authenticated identity for the current request
34 *
35 * Populated by authenticate_request() with the shape
36 * ['type' => 'api_key'|'jwt'|'user'|'nonce', 'id' => <stable string>].
37 *
38 * @var array|null
39 */
40 private $authenticated_identity = null;
41
42 /**
43 * REST namespace
44 */
45 const REST_NAMESPACE = 'metasync/v1';
46
47 /**
48 * REST route
49 */
50 const REST_ROUTE = '/mcp';
51
52 /**
53 * JWT token expiration time (in seconds)
54 * Default: 24 hours
55 */
56 const JWT_EXPIRATION = 86400;
57
58 /**
59 * Default tool-call rate limit (requests per window) for authenticated MCP clients.
60 */
61 const DEFAULT_TOOL_CALL_LIMIT = 60;
62
63 /**
64 * Default tool-call rate-limit window in seconds.
65 */
66 const DEFAULT_TOOL_CALL_WINDOW = 60;
67
68 /**
69 * Constructor
70 */
71 public function __construct() {
72 // Load dependencies
73 $this->load_dependencies();
74
75 // Initialize components
76 $this->json_rpc_handler = new MCP_JSON_RPC_Handler();
77 $this->tool_registry = MCP_Tool_Registry::get_instance();
78
79 // Register handlers
80 $this->register_json_rpc_handlers();
81
82 // WordPress hooks
83 add_action('rest_api_init', [$this, 'register_rest_routes']);
84 }
85
86 /**
87 * Load dependencies
88 */
89 private function load_dependencies() {
90 require_once plugin_dir_path(__FILE__) . 'class-mcp-json-rpc-handler.php';
91 require_once plugin_dir_path(__FILE__) . 'class-mcp-tool-base.php';
92 require_once plugin_dir_path(__FILE__) . 'class-mcp-tool-registry.php';
93 require_once plugin_dir_path(__FILE__) . '../includes/class-metasync-rate-limiter.php';
94 }
95
96 /**
97 * Register JSON-RPC method handlers
98 */
99 private function register_json_rpc_handlers() {
100 $this->json_rpc_handler->register_handler('tools/list', [$this, 'handle_tools_list']);
101 $this->json_rpc_handler->register_handler('tools/call', [$this, 'handle_tools_call']);
102 }
103
104 /**
105 * Register REST routes
106 */
107 public function register_rest_routes() {
108 register_rest_route(self::REST_NAMESPACE, self::REST_ROUTE, [
109 'methods' => 'POST',
110 'callback' => [$this, 'handle_rest_request'],
111 'permission_callback' => [$this, 'check_permissions'],
112 ]);
113
114 // Health check endpoint
115 register_rest_route(self::REST_NAMESPACE, '/mcp/health', [
116 'methods' => 'GET',
117 'callback' => [$this, 'handle_health_check'],
118 'permission_callback' => '__return_true',
119 ]);
120
121 // JWT authentication endpoint
122 register_rest_route(self::REST_NAMESPACE, '/mcp/auth', [
123 'methods' => 'POST',
124 'callback' => [$this, 'handle_jwt_auth'],
125 'permission_callback' => '__return_true',
126 ]);
127 }
128
129 /**
130 * Handle REST request
131 *
132 * @param WP_REST_Request $request Request object
133 * @return WP_REST_Response
134 */
135 public function handle_rest_request($request) {
136 $request_body = $request->get_body();
137
138 // Only rate-limit tools/call requests, not tools/list or other methods.
139 $decoded = json_decode($request_body, true);
140 $is_tool_call = is_array($decoded)
141 && isset($decoded['method'])
142 && $decoded['method'] === 'tools/call';
143
144 if ($is_tool_call && $this->authenticated_identity !== null) {
145 $default_limits = [
146 'max' => self::DEFAULT_TOOL_CALL_LIMIT,
147 'window' => self::DEFAULT_TOOL_CALL_WINDOW,
148 ];
149 $limits = apply_filters('metasync_mcp_tool_call_rate_limit', $default_limits, $this->authenticated_identity);
150
151 $max = isset($limits['max']) ? (int) $limits['max'] : self::DEFAULT_TOOL_CALL_LIMIT;
152 $window = isset($limits['window']) ? (int) $limits['window'] : self::DEFAULT_TOOL_CALL_WINDOW;
153
154 $rate_result = Metasync_Rate_Limiter::get_instance()->check_rate_limit(
155 $this->authenticated_identity['id'],
156 $max,
157 $window,
158 'mcp_tool_'
159 );
160
161 if (is_wp_error($rate_result)) {
162 $error_data = $rate_result->get_error_data();
163 $retry_after = isset($error_data['retry_after']) ? (int) $error_data['retry_after'] : $window;
164
165 $req_id = isset($decoded['id']) ? $decoded['id'] : null;
166
167 $error_body = [
168 'jsonrpc' => '2.0',
169 'id' => $req_id,
170 'error' => [
171 'code' => MCP_JSON_RPC_Handler::ERROR_RATE_LIMITED,
172 'message' => 'Rate limit exceeded',
173 'data' => [
174 'retry_after' => $retry_after,
175 ],
176 ],
177 ];
178
179 $response = new WP_REST_Response($error_body, 429);
180 $response->header('Retry-After', (string) $retry_after);
181 return $response;
182 }
183 }
184
185 // Process through JSON-RPC handler
186 $response = $this->json_rpc_handler->handle_request($request_body);
187
188 return new WP_REST_Response($response, 200);
189 }
190
191 /**
192 * Handle health check
193 *
194 * @return WP_REST_Response
195 */
196 public function handle_health_check() {
197 return new WP_REST_Response([
198 'status' => 'ok',
199 'version' => METASYNC_VERSION,
200 'tools_count' => $this->tool_registry->get_tool_count(),
201 'enabled' => true
202 ], 200);
203 }
204
205 /**
206 * Check permissions
207 *
208 * @param WP_REST_Request $request Request object
209 * @return bool|WP_Error
210 */
211 public function check_permissions($request) {
212 // Check authentication
213 $auth_result = $this->authenticate_request($request);
214 if (is_wp_error($auth_result)) {
215 return $auth_result;
216 }
217
218 // If authenticated via API key, skip capability check
219 // API key already proves admin-level access
220 $api_key = $request->get_header('X-API-Key');
221 if ($api_key && $this->verify_plugin_auth_token($api_key)) {
222 if (!defined('METASYNC_MCP_API_KEY_AUTH')) {
223 define('METASYNC_MCP_API_KEY_AUTH', true);
224 }
225 return true;
226 }
227
228 // If authenticated via JWT, check if it's API key-based or user-based
229 if (defined('METASYNC_MCP_JWT_AUTH') && METASYNC_MCP_JWT_AUTH) {
230 // If JWT was generated from API key (user_id = 0), skip capability check
231 // API key-based JWT tokens have full system-level access
232 if (defined('METASYNC_MCP_API_KEY_AUTH') && METASYNC_MCP_API_KEY_AUTH) {
233 return true;
234 }
235
236 // For user-based JWT tokens, check user capability
237 if (!current_user_can('manage_options')) {
238 return new WP_Error(
239 'insufficient_permissions',
240 'User does not have permission to use the MCP server',
241 ['status' => 403]
242 );
243 }
244 return true;
245 }
246
247 // For nonce-based auth, check user capability
248 if (!current_user_can('manage_options')) {
249 return new WP_Error(
250 'insufficient_permissions',
251 'You do not have permission to use the MCP server',
252 ['status' => 403]
253 );
254 }
255
256 return true;
257 }
258
259 /**
260 * Authenticate request
261 *
262 * Supports three authentication methods:
263 * 1. WordPress nonce (same-origin)
264 * 2. Plugin auth token (external clients)
265 * 3. JWT Bearer token (industry-standard)
266 *
267 * @param WP_REST_Request $request Request object
268 * @return bool|WP_Error
269 */
270 private function authenticate_request($request) {
271 // Method 1: WordPress nonce (for same-origin requests)
272 $nonce = $request->get_header('X-WP-Nonce');
273 if ($nonce && wp_verify_nonce($nonce, 'wp_rest')) {
274 $this->authenticated_identity = [
275 'type' => 'user',
276 'id' => 'user:' . get_current_user_id(),
277 ];
278 return true;
279 }
280
281 // Method 2: Plugin auth token (for external clients like Claude Desktop)
282 $api_key = $request->get_header('X-API-Key');
283 if ($api_key && $this->verify_plugin_auth_token($api_key)) {
284 $this->authenticated_identity = [
285 'type' => 'api_key',
286 'id' => hash('sha256', $api_key),
287 ];
288 return true;
289 }
290
291 // Method 3: JWT Bearer token (industry-standard authentication)
292 $auth_header = $request->get_header('Authorization');
293 if ($auth_header && preg_match('/Bearer\s+(.+)/i', $auth_header, $matches)) {
294 $jwt_token = trim($matches[1]);
295 $jwt_result = $this->verify_jwt_token($jwt_token);
296 if ($jwt_result !== false) {
297 // If user_id is 0, this is an API key-based JWT token (system-level access)
298 // Treat it like API key authentication (no user context needed)
299 if ($jwt_result['user_id'] === 0) {
300 if (!defined('METASYNC_MCP_JWT_AUTH')) {
301 define('METASYNC_MCP_JWT_AUTH', true);
302 }
303 if (!defined('METASYNC_MCP_API_KEY_AUTH')) {
304 define('METASYNC_MCP_API_KEY_AUTH', true);
305 }
306 $this->authenticated_identity = [
307 'type' => 'api_key',
308 'id' => $jwt_result['sub'],
309 ];
310 } else {
311 // Set the authenticated user from JWT
312 wp_set_current_user($jwt_result['user_id']);
313 if (!defined('METASYNC_MCP_JWT_AUTH')) {
314 define('METASYNC_MCP_JWT_AUTH', true);
315 }
316 $this->authenticated_identity = [
317 'type' => 'user',
318 'id' => $jwt_result['sub'],
319 ];
320 }
321 return true;
322 }
323 }
324
325 return new WP_Error(
326 'authentication_failed',
327 'Authentication required. Provide X-WP-Nonce, X-API-Key, or Authorization: Bearer <jwt_token> header.',
328 ['status' => 401]
329 );
330 }
331
332 /**
333 * Verify plugin auth token
334 *
335 * @param string $provided_token Provided auth token
336 * @return bool
337 */
338 private function verify_plugin_auth_token($provided_token) {
339 $options = get_option('metasync_options', []);
340 $stored_token = isset($options['general']['apikey']) ? $options['general']['apikey'] : null;
341
342 if (empty($stored_token)) {
343 return false;
344 }
345
346 return hash_equals($stored_token, $provided_token);
347 }
348
349 /**
350 * Handle tools/list method
351 *
352 * @param array $params Request parameters
353 * @return array
354 */
355 public function handle_tools_list($params) {
356 $tools = $this->tool_registry->get_tools_list();
357
358 return [
359 'tools' => $tools
360 ];
361 }
362
363 /**
364 * Handle tools/call method
365 *
366 * @param array $params Request parameters
367 * @return array
368 * @throws InvalidArgumentException If parameters are invalid
369 */
370 public function handle_tools_call($params) {
371 // Validate params
372 if (!isset($params['name'])) {
373 throw new InvalidArgumentException('Missing required parameter: name');
374 }
375
376 $tool_name = $params['name'];
377 $tool_params = isset($params['arguments']) ? $params['arguments'] : [];
378
379 // Execute tool
380 try {
381 $result = $this->tool_registry->execute_tool($tool_name, $tool_params);
382
383 return [
384 'content' => [
385 [
386 'type' => 'text',
387 'text' => json_encode($result, JSON_PRETTY_PRINT)
388 ]
389 ]
390 ];
391 } catch (InvalidArgumentException $e) {
392 throw $e;
393 } catch (Exception $e) {
394 throw new Exception('Tool execution failed: ' . $e->getMessage());
395 }
396 }
397
398 /**
399 * Register a tool
400 *
401 * @param MCP_Tool_Base $tool Tool instance
402 * @return bool
403 */
404 public function register_tool(MCP_Tool_Base $tool) {
405 return $this->tool_registry->register_tool($tool);
406 }
407
408 /**
409 * Get tool registry instance
410 * Allows internal components (like OTTO integration) to call MCP tools directly
411 *
412 * @return MCP_Tool_Registry
413 */
414 public function get_tool_registry() {
415 return $this->tool_registry;
416 }
417
418 /**
419 * Get plugin auth token
420 *
421 * @return string|false
422 */
423 public function get_api_key() {
424 $options = get_option('metasync_options', []);
425 return isset($options['general']['apikey']) ? $options['general']['apikey'] : false;
426 }
427
428 /**
429 * Get server info
430 *
431 * @return array
432 */
433 public function get_server_info() {
434 return [
435 'enabled' => true,
436 'endpoint' => rest_url(self::REST_NAMESPACE . self::REST_ROUTE),
437 'tools_count' => $this->tool_registry->get_tool_count(),
438 'version' => METASYNC_VERSION,
439 'has_auth_token' => !empty($this->get_api_key())
440 ];
441 }
442
443 /**
444 * Handle JWT authentication request
445 * Generates a JWT token by exchanging plugin API key for time-limited JWT
446 *
447 * @param WP_REST_Request $request Request object
448 * @return WP_REST_Response|WP_Error
449 */
450 public function handle_jwt_auth($request) {
451 $ip = isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '0.0.0.0';
452
453 // Enforce brute-force rate limit before any further processing
454 $rate_limit_result = $this->check_rate_limit($ip);
455 if (is_wp_error($rate_limit_result)) {
456 return $rate_limit_result;
457 }
458
459 $params = $request->get_json_params();
460
461 // Get API key from request body or header
462 $api_key = '';
463 if (isset($params['api_key'])) {
464 $api_key = sanitize_text_field($params['api_key']);
465 } else {
466 $api_key = $request->get_header('X-API-Key');
467 }
468
469 if (empty($api_key)) {
470 $this->record_failed_auth_attempt($ip);
471 return new WP_Error(
472 'missing_api_key',
473 'API key is required. Provide in request body as "api_key" or in X-API-Key header.',
474 ['status' => 400]
475 );
476 }
477
478 // Verify API key
479 if (!$this->verify_plugin_auth_token($api_key)) {
480 $this->record_failed_auth_attempt($ip);
481 return new WP_Error(
482 'invalid_api_key',
483 'Invalid API key',
484 ['status' => 401]
485 );
486 }
487
488 // Successful authentication — clear any accumulated failure counters
489 $this->clear_auth_attempts($ip);
490
491 // Generate JWT token
492 // Use 0 as user_id to indicate API key authentication (system-level access)
493 $token = $this->generate_jwt_token(0);
494
495 if ($token === false) {
496 return new WP_Error(
497 'token_generation_failed',
498 'Failed to generate JWT token',
499 ['status' => 500]
500 );
501 }
502
503 return new WP_REST_Response([
504 'success' => true,
505 'token' => $token,
506 'token_type' => 'Bearer',
507 'expires_in' => self::JWT_EXPIRATION,
508 'expires_at' => time() + self::JWT_EXPIRATION,
509 'scope' => 'mcp:full_access'
510 ], 200);
511 }
512
513 /**
514 * Generate JWT token
515 *
516 * @param int $user_id WordPress user ID (0 for API key based tokens)
517 * @return string|false JWT token or false on failure
518 */
519 private function generate_jwt_token($user_id) {
520 $issued_at = time();
521 $expiration = $issued_at + self::JWT_EXPIRATION;
522
523 // JWT header
524 $header = [
525 'alg' => 'HS256',
526 'typ' => 'JWT'
527 ];
528
529 // JWT payload
530 // user_id = 0 indicates API key authentication (system-level access)
531 $payload = [
532 'sub' => $user_id === 0 ? 'api_key' : 'user:' . $user_id,
533 'user_id' => $user_id,
534 'iat' => $issued_at,
535 'exp' => $expiration,
536 'iss' => get_site_url(),
537 'scope' => 'mcp:full_access'
538 ];
539
540 // Encode header and payload
541 $header_encoded = $this->base64_url_encode(json_encode($header));
542 $payload_encoded = $this->base64_url_encode(json_encode($payload));
543
544 // Create signature
545 $signature_input = $header_encoded . '.' . $payload_encoded;
546 $secret = $this->get_jwt_secret();
547 $signature = hash_hmac('sha256', $signature_input, $secret, true);
548 $signature_encoded = $this->base64_url_encode($signature);
549
550 // Create JWT token
551 $jwt = $header_encoded . '.' . $payload_encoded . '.' . $signature_encoded;
552
553 return $jwt;
554 }
555
556 /**
557 * Verify JWT token
558 *
559 * @param string $token JWT token
560 * @return array|false Decoded payload or false on failure
561 */
562 private function verify_jwt_token($token) {
563 // Split token into parts
564 $parts = explode('.', $token);
565
566 if (count($parts) !== 3) {
567 return false;
568 }
569
570 list($header_encoded, $payload_encoded, $signature_encoded) = $parts;
571
572 // Validate header: enforce alg=HS256 and typ=JWT to defeat
573 // algorithm-confusion attacks (e.g. "alg":"none").
574 $header_json = $this->base64_url_decode($header_encoded);
575 $header = json_decode($header_json, true);
576 if (!is_array($header)) {
577 return false;
578 }
579 if (!isset($header['alg']) || $header['alg'] !== 'HS256') {
580 return false;
581 }
582 if (!isset($header['typ']) || $header['typ'] !== 'JWT') {
583 return false;
584 }
585
586 // Verify signature
587 $signature_input = $header_encoded . '.' . $payload_encoded;
588 $secret = $this->get_jwt_secret();
589 $signature = hash_hmac('sha256', $signature_input, $secret, true);
590 $signature_expected = $this->base64_url_encode($signature);
591
592 if (!hash_equals($signature_expected, $signature_encoded)) {
593 return false;
594 }
595
596 // Decode payload
597 $payload_json = $this->base64_url_decode($payload_encoded);
598 $payload = json_decode($payload_json, true);
599
600 if (!$payload) {
601 return false;
602 }
603
604 // Check expiration — exp is mandatory; reject tokens missing or
605 // with a non-numeric / past expiry.
606 if (!isset($payload['exp']) || !is_numeric($payload['exp']) || (int) $payload['exp'] < time()) {
607 return false;
608 }
609
610 // Check issuer — iss is mandatory and must match this site.
611 if (!isset($payload['iss']) || $payload['iss'] !== get_site_url()) {
612 return false;
613 }
614
615 // Check user_id exists
616 if (!isset($payload['user_id'])) {
617 return false;
618 }
619
620 // If user_id is 0, this is an API key-based token (system-level access)
621 // No need to validate user existence
622 if ($payload['user_id'] === 0) {
623 return $payload;
624 }
625
626 // For user-based tokens, verify user exists
627 $user = get_user_by('id', $payload['user_id']);
628 if (!$user) {
629 return false;
630 }
631
632 return $payload;
633 }
634
635 /**
636 * Get JWT secret key
637 *
638 * Uses a dedicated random secret stored in the WordPress options table.
639 * A new secret is generated on first use and persisted so that existing
640 * tokens remain valid across requests.
641 *
642 * @return string
643 */
644 private function get_jwt_secret() {
645 $secret = get_option('metasync_jwt_secret');
646 if (empty($secret)) {
647 $secret = wp_generate_password(64, true, true);
648 update_option('metasync_jwt_secret', $secret, false);
649 }
650 return $secret;
651 }
652
653 /**
654 * Check whether the given IP has exceeded the authentication rate limit.
655 *
656 * Allows up to 10 failed attempts within any 15-minute window. Returns a
657 * WP_Error with HTTP 429 if the limit is exceeded, or true otherwise.
658 *
659 * @param string $ip Client IP address
660 * @return true|WP_Error
661 */
662 private function check_rate_limit($ip) {
663 $key = 'metasync_auth_attempts_' . md5($ip);
664 $attempts = (int) get_transient($key);
665 if ($attempts >= 10) {
666 return new WP_Error(
667 'too_many_requests',
668 'Too many failed authentication attempts. Try again later.',
669 ['status' => 429]
670 );
671 }
672 return true;
673 }
674
675 /**
676 * Increment the failed-authentication counter for the given IP.
677 *
678 * The counter expires automatically after 15 minutes.
679 *
680 * @param string $ip Client IP address
681 * @return void
682 */
683 private function record_failed_auth_attempt($ip) {
684 global $wpdb;
685 $key = 'metasync_auth_attempts_' . md5($ip);
686 $window = 15 * MINUTE_IN_SECONDS;
687
688 // Use get_transient to check existence first
689 $attempts = get_transient($key);
690 if ($attempts === false) {
691 // First attempt: set initial value atomically
692 set_transient($key, 1, $window);
693 } else {
694 // Increment atomically via direct SQL to avoid race conditions
695 $option_name = '_transient_' . $key;
696 $updated = $wpdb->query(
697 $wpdb->prepare(
698 "UPDATE {$wpdb->options} SET option_value = option_value + 1 WHERE option_name = %s",
699 $option_name
700 )
701 );
702 // If no rows updated (e.g. object cache backend), fall back to set
703 if ($updated === 0) {
704 set_transient($key, (int) $attempts + 1, $window);
705 }
706 }
707 }
708
709 /**
710 * Clear the failed-authentication counter for the given IP.
711 *
712 * Called on successful authentication to reset brute-force tracking.
713 *
714 * @param string $ip Client IP address
715 * @return void
716 */
717 private function clear_auth_attempts($ip) {
718 $key = 'metasync_auth_attempts_' . md5($ip);
719 delete_transient($key);
720 }
721
722 /**
723 * Base64 URL encode
724 *
725 * @param string $data Data to encode
726 * @return string
727 */
728 private function base64_url_encode($data) {
729 return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
730 }
731
732 /**
733 * Base64 URL decode
734 *
735 * @param string $data Data to decode
736 * @return string
737 */
738 private function base64_url_decode($data) {
739 return base64_decode(strtr($data, '-_', '+/'));
740 }
741 }
742