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

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

707 lines 21.8 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 // Verify signature
573 $signature_input = $header_encoded . '.' . $payload_encoded;
574 $secret = $this->get_jwt_secret();
575 $signature = hash_hmac('sha256', $signature_input, $secret, true);
576 $signature_expected = $this->base64_url_encode($signature);
577
578 if (!hash_equals($signature_expected, $signature_encoded)) {
579 return false;
580 }
581
582 // Decode payload
583 $payload_json = $this->base64_url_decode($payload_encoded);
584 $payload = json_decode($payload_json, true);
585
586 if (!$payload) {
587 return false;
588 }
589
590 // Check expiration
591 if (isset($payload['exp']) && $payload['exp'] < time()) {
592 return false;
593 }
594
595 // Check issuer
596 if (isset($payload['iss']) && $payload['iss'] !== get_site_url()) {
597 return false;
598 }
599
600 // Check user_id exists
601 if (!isset($payload['user_id'])) {
602 return false;
603 }
604
605 // If user_id is 0, this is an API key-based token (system-level access)
606 // No need to validate user existence
607 if ($payload['user_id'] === 0) {
608 return $payload;
609 }
610
611 // For user-based tokens, verify user exists
612 $user = get_user_by('id', $payload['user_id']);
613 if (!$user) {
614 return false;
615 }
616
617 return $payload;
618 }
619
620 /**
621 * Get JWT secret key
622 *
623 * Uses a dedicated random secret stored in the WordPress options table.
624 * A new secret is generated on first use and persisted so that existing
625 * tokens remain valid across requests.
626 *
627 * @return string
628 */
629 private function get_jwt_secret() {
630 $secret = get_option('metasync_jwt_secret');
631 if (empty($secret)) {
632 $secret = wp_generate_password(64, true, true);
633 update_option('metasync_jwt_secret', $secret, false);
634 }
635 return $secret;
636 }
637
638 /**
639 * Check whether the given IP has exceeded the authentication rate limit.
640 *
641 * Allows up to 10 failed attempts within any 15-minute window. Returns a
642 * WP_Error with HTTP 429 if the limit is exceeded, or true otherwise.
643 *
644 * @param string $ip Client IP address
645 * @return true|WP_Error
646 */
647 private function check_rate_limit($ip) {
648 $key = 'metasync_auth_attempts_' . md5($ip);
649 $attempts = (int) get_transient($key);
650 if ($attempts >= 10) {
651 return new WP_Error(
652 'too_many_requests',
653 'Too many failed authentication attempts. Try again later.',
654 ['status' => 429]
655 );
656 }
657 return true;
658 }
659
660 /**
661 * Increment the failed-authentication counter for the given IP.
662 *
663 * The counter expires automatically after 15 minutes.
664 *
665 * @param string $ip Client IP address
666 * @return void
667 */
668 private function record_failed_auth_attempt($ip) {
669 $key = 'metasync_auth_attempts_' . md5($ip);
670 $attempts = (int) get_transient($key);
671 set_transient($key, $attempts + 1, 15 * MINUTE_IN_SECONDS);
672 }
673
674 /**
675 * Clear the failed-authentication counter for the given IP.
676 *
677 * Called on successful authentication to reset brute-force tracking.
678 *
679 * @param string $ip Client IP address
680 * @return void
681 */
682 private function clear_auth_attempts($ip) {
683 $key = 'metasync_auth_attempts_' . md5($ip);
684 delete_transient($key);
685 }
686
687 /**
688 * Base64 URL encode
689 *
690 * @param string $data Data to encode
691 * @return string
692 */
693 private function base64_url_encode($data) {
694 return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
695 }
696
697 /**
698 * Base64 URL decode
699 *
700 * @param string $data Data to decode
701 * @return string
702 */
703 private function base64_url_decode($data) {
704 return base64_decode(strtr($data, '-_', '+/'));
705 }
706 }
707