PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.0
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.0
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.7.0, at wp-mcp-server/class-metasync-mcp-server.php

1,389 lines 47.0 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 * Failed-authentication attempts allowed per source within the window.
70 */
71 const AUTH_FAILURE_LIMIT = 10;
72
73 /**
74 * Failed-authentication window in seconds (per source and site-wide).
75 */
76 const AUTH_FAILURE_WINDOW = 900;
77
78 /**
79 * Site-wide failed-authentication ceiling within the window.
80 *
81 * Backstop for attackers who rotate their source address: the per-source
82 * bucket alone cannot hold them, this one can.
83 */
84 const AUTH_FAILURE_SITE_LIMIT = 100;
85
86 /**
87 * Upper bound on the escalated lockout, in seconds.
88 */
89 const AUTH_FAILURE_MAX_LOCKOUT = 3600;
90
91 /**
92 * Bucket name used for the site-wide failed-authentication counter.
93 */
94 const AUTH_FAILURE_SITE_BUCKET = '__site__';
95
96 /**
97 * Prefix for failed-authentication counter transients.
98 */
99 const AUTH_FAILURE_PREFIX = 'metasync_mcp_auth_';
100
101 /**
102 * Evaluate every failed-authentication bucket, including the site-wide one.
103 */
104 const AUTH_SCOPE_ALL = 'all';
105
106 /**
107 * Evaluate only the caller's own bucket, ignoring the site-wide ceiling.
108 *
109 * Used for the pre-authentication gate: the site-wide ceiling must never
110 * decide whether a *valid* credential is accepted.
111 */
112 const AUTH_SCOPE_SOURCE = 'source';
113
114 /**
115 * The request whose permission decision is memoised below.
116 *
117 * WordPress calls a route's permission_callback twice per request: once
118 * from WP_REST_Server::respond_to_request() to authorise the call, and
119 * again from rest_send_allow_header() on rest_post_dispatch to build the
120 * Allow header. A callback with side effects therefore fires twice, which
121 * would spend the caller's failure budget at double the documented rate.
122 *
123 * The request object itself is held rather than an spl_object_id: ids are
124 * recycled once an object is freed, so a later request could collide with
125 * a released one and silently inherit its decision. Keeping the reference
126 * makes identity comparison exact.
127 *
128 * @var WP_REST_Request|null
129 */
130 private $permission_request = null;
131
132 /**
133 * Memoised decision for $permission_request.
134 *
135 * @var bool|WP_Error|null
136 */
137 private $permission_decision = null;
138
139 /**
140 * Constructor
141 */
142 public function __construct() {
143 // Load dependencies
144 $this->load_dependencies();
145
146 // Initialize components
147 $this->json_rpc_handler = new MCP_JSON_RPC_Handler();
148 $this->tool_registry = MCP_Tool_Registry::get_instance();
149
150 // Register handlers
151 $this->register_json_rpc_handlers();
152
153 // WordPress hooks
154 add_action('rest_api_init', [$this, 'register_rest_routes']);
155 }
156
157 /**
158 * Load dependencies
159 */
160 private function load_dependencies() {
161 require_once plugin_dir_path(__FILE__) . 'class-mcp-json-rpc-handler.php';
162 require_once plugin_dir_path(__FILE__) . 'class-mcp-tool-base.php';
163 require_once plugin_dir_path(__FILE__) . 'class-mcp-tool-registry.php';
164 require_once plugin_dir_path(__FILE__) . '../includes/class-metasync-rate-limiter.php';
165 }
166
167 /**
168 * Register JSON-RPC method handlers
169 */
170 private function register_json_rpc_handlers() {
171 $this->json_rpc_handler->register_handler('tools/list', [$this, 'handle_tools_list']);
172 $this->json_rpc_handler->register_handler('tools/call', [$this, 'handle_tools_call']);
173 $this->json_rpc_handler->register_handler('notifications/initialized', [$this, 'handle_initialized_notification']);
174 }
175
176 /**
177 * Register REST routes
178 */
179 public function register_rest_routes() {
180 register_rest_route(self::REST_NAMESPACE, self::REST_ROUTE, [
181 'methods' => 'POST',
182 'callback' => [$this, 'handle_rest_request'],
183 'permission_callback' => [$this, 'check_permissions'],
184 ]);
185
186 // Health check endpoint
187 register_rest_route(self::REST_NAMESPACE, '/mcp/health', [
188 'methods' => 'GET',
189 'callback' => [$this, 'handle_health_check'],
190 'permission_callback' => '__return_true',
191 ]);
192
193 // JWT authentication endpoint
194 register_rest_route(self::REST_NAMESPACE, '/mcp/auth', [
195 'methods' => 'POST',
196 'callback' => [$this, 'handle_jwt_auth'],
197 'permission_callback' => '__return_true',
198 ]);
199 }
200
201 /**
202 * Handle REST request
203 *
204 * @param WP_REST_Request $request Request object
205 * @return WP_REST_Response
206 */
207 public function handle_rest_request($request) {
208 $result = $this->process_json_rpc_request($request->get_body());
209 $response = new WP_REST_Response($result['body'], $result['status']);
210
211 foreach ($result['headers'] as $name => $value) {
212 $response->header($name, $value);
213 }
214
215 return $response;
216 }
217
218 /**
219 * Authenticate a PHP bridge and establish its rate-limit identity.
220 *
221 * HTTP bridges pass the request header. Stdio bridges pass the key from
222 * their private process environment.
223 *
224 * @param string|false $provided_key Bridge API key.
225 * @return true|WP_Error
226 */
227 public function authenticate_bridge_request($provided_key) {
228 $this->authenticated_identity = null;
229 $configured_key = getenv('WP_MCP_API_KEY');
230
231 if (!is_string($configured_key) || $configured_key === '') {
232 return new WP_Error(
233 'bridge_auth_not_configured',
234 'MCP bridge authentication is not configured.',
235 ['status' => 401]
236 );
237 }
238
239 if (!is_string($provided_key) || !hash_equals($configured_key, $provided_key)) {
240 return new WP_Error(
241 'bridge_authentication_failed',
242 'Invalid or missing API key.',
243 ['status' => 401]
244 );
245 }
246
247 $this->authenticated_identity = [
248 'type' => 'api_key',
249 'id' => hash('sha256', $configured_key),
250 ];
251
252 if (!defined('METASYNC_MCP_API_KEY_AUTH')) {
253 define('METASYNC_MCP_API_KEY_AUTH', true);
254 }
255
256 return true;
257 }
258
259 /**
260 * Process authenticated JSON-RPC consistently for every transport.
261 *
262 * @param string $request_body Raw JSON-RPC request body.
263 * @return array{body: array|null, status: int, headers: array}
264 */
265 public function process_json_rpc_request($request_body) {
266 if ($this->authenticated_identity === null) {
267 return [
268 'body' => [
269 'jsonrpc' => '2.0',
270 'id' => null,
271 'error' => [
272 'code' => MCP_JSON_RPC_Handler::ERROR_SERVER_ERROR,
273 'message' => 'Authentication required',
274 ],
275 ],
276 'status' => 401,
277 'headers' => [],
278 ];
279 }
280
281 // Only rate-limit tools/call requests, not tools/list or other methods.
282 $decoded = json_decode($request_body, true);
283 $is_tool_call = is_array($decoded)
284 && isset($decoded['method'])
285 && $decoded['method'] === 'tools/call';
286 $is_notification = is_array($decoded) && !array_key_exists('id', $decoded);
287
288 if ($is_tool_call && $this->authenticated_identity !== null) {
289 $default_limits = [
290 'max' => self::DEFAULT_TOOL_CALL_LIMIT,
291 'window' => self::DEFAULT_TOOL_CALL_WINDOW,
292 ];
293 $limits = apply_filters('metasync_mcp_tool_call_rate_limit', $default_limits, $this->authenticated_identity);
294
295 $max = isset($limits['max']) ? (int) $limits['max'] : self::DEFAULT_TOOL_CALL_LIMIT;
296 $window = isset($limits['window']) ? (int) $limits['window'] : self::DEFAULT_TOOL_CALL_WINDOW;
297
298 $rate_result = Metasync_Rate_Limiter::get_instance()->check_rate_limit(
299 $this->authenticated_identity['id'],
300 $max,
301 $window,
302 'mcp_tool_'
303 );
304
305 if (is_wp_error($rate_result)) {
306 $error_data = $rate_result->get_error_data();
307 $retry_after = isset($error_data['retry_after']) ? (int) $error_data['retry_after'] : $window;
308
309 $req_id = isset($decoded['id']) ? $decoded['id'] : null;
310
311 $error_body = [
312 'jsonrpc' => '2.0',
313 'id' => $req_id,
314 'error' => [
315 'code' => MCP_JSON_RPC_Handler::ERROR_RATE_LIMITED,
316 'message' => 'Rate limit exceeded',
317 'data' => [
318 'retry_after' => $retry_after,
319 ],
320 ],
321 ];
322
323 return [
324 'body' => $is_notification ? null : $error_body,
325 'status' => 429,
326 'headers' => ['Retry-After' => (string) $retry_after],
327 ];
328 }
329 }
330
331 return [
332 'body' => $this->json_rpc_handler->handle_request($request_body),
333 'status' => 200,
334 'headers' => [],
335 ];
336 }
337
338 /**
339 * Handle health check
340 *
341 * @return WP_REST_Response
342 */
343 public function handle_health_check() {
344 return new WP_REST_Response([
345 'status' => 'ok',
346 'version' => METASYNC_VERSION,
347 'tools_count' => $this->tool_registry->get_tool_count(),
348 'enabled' => true
349 ], 200);
350 }
351
352 /**
353 * Check permissions
354 *
355 * Failed authentication is rate limited here rather than inside
356 * authenticate_request(): this is the permission_callback, so it is the
357 * only seam that runs before WordPress decides whether to invoke the
358 * route callback at all. Every rejected attempt is recorded before the
359 * response is chosen, so the request that trips the limit is itself
360 * counted.
361 *
362 * @param WP_REST_Request $request Request object
363 * @return bool|WP_Error
364 */
365 public function check_permissions($request) {
366 // Core asks twice per request (authorisation, then the Allow header).
367 // Only the first pass may record anything.
368 if ($this->permission_request !== null && $this->permission_request === $request) {
369 return $this->permission_decision;
370 }
371
372 $this->permission_request = $request;
373 $this->permission_decision = $this->evaluate_permissions($request);
374
375 return $this->permission_decision;
376 }
377
378 /**
379 * Evaluate the permission decision for a request.
380 *
381 * Side effects (recording a failure, clearing a counter) belong here, and
382 * check_permissions() guarantees this runs at most once per request.
383 *
384 * @param WP_REST_Request $request Request object
385 * @return bool|WP_Error
386 */
387 private function evaluate_permissions($request) {
388 $source = $this->get_auth_source_key();
389
390 // Already locked out: refuse before verifying anything, otherwise the
391 // caller could keep guessing credentials while "rate limited".
392 //
393 // Only this caller's own bucket gates the pre-authentication path.
394 // The site-wide ceiling is deliberately left out here and applied to
395 // failures below instead: were it enforced before the credential is
396 // read, an attacker filling the site bucket from throwaway addresses
397 // would lock out the legitimate client holding the correct key, and
398 // every poll that client made would escalate its own bucket on the
399 // way past. That turns a brute-force defence into a denial-of-service
400 // lever, so the ceiling only ever rejects an attempt that has already
401 // failed to authenticate.
402 $lockout = $this->check_rate_limit($source, false, self::AUTH_SCOPE_SOURCE);
403 if (is_wp_error($lockout)) {
404 $this->escalate_auth_lockout($source);
405 return $lockout;
406 }
407
408 // Check authentication
409 $auth_result = $this->authenticate_request($request);
410 if (is_wp_error($auth_result)) {
411 // A revoked token is not a guess. Producing one takes a valid
412 // signature over the current secret, so the caller held a real
413 // credential and simply needs to re-authenticate after a key
414 // rotation, a disconnect, or an upgrade that rebound tokens.
415 // Charging it would drain the very bucket the auth endpoint
416 // consults, locking a legitimate client out of its own recovery.
417 if ($auth_result->get_error_code() !== 'token_revoked') {
418 // Record this failure, then re-evaluate. Recording first means
419 // the attempt that crosses the threshold is counted rather
420 // than given away for free.
421 $limited = $this->check_rate_limit($source, true);
422 if (is_wp_error($limited)) {
423 return $limited;
424 }
425 }
426
427 return $auth_result;
428 }
429
430 // Successful authentication clears the source counter so legitimate
431 // clients are never penalised for an earlier typo or key rotation.
432 $this->clear_auth_attempts($source);
433
434 // If authenticated via API key, skip capability check
435 // API key already proves admin-level access
436 $api_key = $request->get_header('X-API-Key');
437 if ($api_key && $this->verify_plugin_auth_token($api_key)) {
438 if (!defined('METASYNC_MCP_API_KEY_AUTH')) {
439 define('METASYNC_MCP_API_KEY_AUTH', true);
440 }
441 return true;
442 }
443
444 // If authenticated via JWT, check if it's API key-based or user-based
445 if (defined('METASYNC_MCP_JWT_AUTH') && METASYNC_MCP_JWT_AUTH) {
446 // If JWT was generated from API key (user_id = 0), skip capability check
447 // API key-based JWT tokens have full system-level access
448 if (defined('METASYNC_MCP_API_KEY_AUTH') && METASYNC_MCP_API_KEY_AUTH) {
449 return true;
450 }
451
452 // For user-based JWT tokens, check user capability
453 if (!current_user_can('manage_options')) {
454 return new WP_Error(
455 'insufficient_permissions',
456 'User does not have permission to use the MCP server',
457 ['status' => 403]
458 );
459 }
460 return true;
461 }
462
463 // For nonce-based auth, check user capability
464 if (!current_user_can('manage_options')) {
465 return new WP_Error(
466 'insufficient_permissions',
467 'You do not have permission to use the MCP server',
468 ['status' => 403]
469 );
470 }
471
472 return true;
473 }
474
475 /**
476 * Authenticate request
477 *
478 * Supports three authentication methods:
479 * 1. WordPress nonce (same-origin)
480 * 2. Plugin auth token (external clients)
481 * 3. JWT Bearer token (industry-standard)
482 *
483 * @param WP_REST_Request $request Request object
484 * @return bool|WP_Error
485 */
486 private function authenticate_request($request) {
487 // Method 1: WordPress nonce (for same-origin requests)
488 $nonce = $request->get_header('X-WP-Nonce');
489 if ($nonce && wp_verify_nonce($nonce, 'wp_rest')) {
490 $this->authenticated_identity = [
491 'type' => 'user',
492 'id' => 'user:' . get_current_user_id(),
493 ];
494 return true;
495 }
496
497 // Method 2: Plugin auth token (for external clients like Claude Desktop)
498 $api_key = $request->get_header('X-API-Key');
499 if ($api_key && $this->verify_plugin_auth_token($api_key)) {
500 $this->authenticated_identity = [
501 'type' => 'api_key',
502 'id' => hash('sha256', $api_key),
503 ];
504 return true;
505 }
506
507 // Method 3: JWT Bearer token (industry-standard authentication)
508 $auth_header = $request->get_header('Authorization');
509 if ($auth_header && preg_match('/Bearer\s+(.+)/i', $auth_header, $matches)) {
510 $jwt_token = trim($matches[1]);
511 $jwt_revoked = false;
512 $jwt_result = $this->verify_jwt_token($jwt_token, $jwt_revoked);
513 if ($jwt_result !== false) {
514 // If user_id is 0, this is an API key-based JWT token (system-level access)
515 // Treat it like API key authentication (no user context needed)
516 if ($jwt_result['user_id'] === 0) {
517 if (!defined('METASYNC_MCP_JWT_AUTH')) {
518 define('METASYNC_MCP_JWT_AUTH', true);
519 }
520 if (!defined('METASYNC_MCP_API_KEY_AUTH')) {
521 define('METASYNC_MCP_API_KEY_AUTH', true);
522 }
523 $this->authenticated_identity = [
524 'type' => 'api_key',
525 'id' => $jwt_result['sub'],
526 ];
527 } else {
528 // Set the authenticated user from JWT
529 wp_set_current_user($jwt_result['user_id']);
530 if (!defined('METASYNC_MCP_JWT_AUTH')) {
531 define('METASYNC_MCP_JWT_AUTH', true);
532 }
533 $this->authenticated_identity = [
534 'type' => 'user',
535 'id' => $jwt_result['sub'],
536 ];
537 }
538 return true;
539 }
540
541 if ($jwt_revoked) {
542 return new WP_Error(
543 'token_revoked',
544 'This token is no longer valid for the current API key. Request a new one from the auth endpoint.',
545 ['status' => 401]
546 );
547 }
548 }
549
550 return new WP_Error(
551 'authentication_failed',
552 'Authentication required. Provide X-WP-Nonce, X-API-Key, or Authorization: Bearer <jwt_token> header.',
553 ['status' => 401]
554 );
555 }
556
557 /**
558 * Verify plugin auth token
559 *
560 * @param string $provided_token Provided auth token
561 * @return bool
562 */
563 private function verify_plugin_auth_token($provided_token) {
564 $options = get_option('metasync_options', []);
565 $stored_token = isset($options['general']['apikey']) ? $options['general']['apikey'] : null;
566
567 if (empty($stored_token)) {
568 return false;
569 }
570
571 return hash_equals($stored_token, $provided_token);
572 }
573
574 /**
575 * Handle tools/list method
576 *
577 * @param array $params Request parameters
578 * @return array
579 */
580 public function handle_tools_list($params) {
581 $tools = $this->tool_registry->get_tools_list();
582
583 return [
584 'tools' => $tools
585 ];
586 }
587
588 /**
589 * Acknowledge the client initialization notification.
590 *
591 * JSON-RPC notifications intentionally produce no response body.
592 *
593 * @param array $params Notification parameters.
594 * @return null
595 */
596 public function handle_initialized_notification($params) {
597 return null;
598 }
599
600 /**
601 * Handle tools/call method
602 *
603 * @param array $params Request parameters
604 * @return array
605 * @throws InvalidArgumentException If parameters are invalid
606 */
607 public function handle_tools_call($params) {
608 // Validate params
609 if (!isset($params['name'])) {
610 throw new InvalidArgumentException('Missing required parameter: name');
611 }
612
613 $tool_name = $params['name'];
614 $tool_params = isset($params['arguments']) ? $params['arguments'] : [];
615
616 // Execute tool
617 try {
618 $result = $this->tool_registry->execute_tool($tool_name, $tool_params);
619
620 return [
621 'content' => [
622 [
623 'type' => 'text',
624 'text' => json_encode($result, JSON_PRETTY_PRINT)
625 ]
626 ]
627 ];
628 } catch (InvalidArgumentException $e) {
629 throw $e;
630 } catch (Exception $e) {
631 throw new Exception('Tool execution failed: ' . $e->getMessage());
632 }
633 }
634
635 /**
636 * Register a tool
637 *
638 * @param MCP_Tool_Base $tool Tool instance
639 * @return bool
640 */
641 public function register_tool(MCP_Tool_Base $tool) {
642 return $this->tool_registry->register_tool($tool);
643 }
644
645 /**
646 * Get tool registry instance
647 * Allows internal components (like OTTO integration) to call MCP tools directly
648 *
649 * @return MCP_Tool_Registry
650 */
651 public function get_tool_registry() {
652 return $this->tool_registry;
653 }
654
655 /**
656 * Get plugin auth token
657 *
658 * @return string|false
659 */
660 public function get_api_key() {
661 $options = get_option('metasync_options', []);
662 return isset($options['general']['apikey']) ? $options['general']['apikey'] : false;
663 }
664
665 /**
666 * Get server info
667 *
668 * @return array
669 */
670 public function get_server_info() {
671 return [
672 'enabled' => true,
673 'endpoint' => rest_url(self::REST_NAMESPACE . self::REST_ROUTE),
674 'tools_count' => $this->tool_registry->get_tool_count(),
675 'version' => METASYNC_VERSION,
676 'has_auth_token' => !empty($this->get_api_key())
677 ];
678 }
679
680 /**
681 * Handle JWT authentication request
682 * Generates a JWT token by exchanging plugin API key for time-limited JWT
683 *
684 * @param WP_REST_Request $request Request object
685 * @return WP_REST_Response|WP_Error
686 */
687 public function handle_jwt_auth($request) {
688 $source = $this->get_auth_source_key();
689
690 // Enforce brute-force rate limit before any further processing.
691 // Source-scoped for the same reason as check_permissions(): a valid
692 // key must still be able to mint a token while the site-wide bucket
693 // is full of somebody else's failures.
694 $rate_limit_result = $this->check_rate_limit($source, false, self::AUTH_SCOPE_SOURCE);
695 if (is_wp_error($rate_limit_result)) {
696 return $rate_limit_result;
697 }
698
699 $params = $request->get_json_params();
700
701 // Get API key from request body or header
702 $api_key = '';
703 if (isset($params['api_key'])) {
704 $api_key = sanitize_text_field($params['api_key']);
705 } else {
706 $api_key = $request->get_header('X-API-Key');
707 }
708
709 if (empty($api_key)) {
710 $limited = $this->check_rate_limit($source, true);
711 if (is_wp_error($limited)) {
712 return $limited;
713 }
714
715 return new WP_Error(
716 'missing_api_key',
717 'API key is required. Provide in request body as "api_key" or in X-API-Key header.',
718 ['status' => 400]
719 );
720 }
721
722 // Verify API key
723 if (!$this->verify_plugin_auth_token($api_key)) {
724 $limited = $this->check_rate_limit($source, true);
725 if (is_wp_error($limited)) {
726 return $limited;
727 }
728
729 return new WP_Error(
730 'invalid_api_key',
731 'Invalid API key',
732 ['status' => 401]
733 );
734 }
735
736 // Successful authentication — clear any accumulated failure counters
737 $this->clear_auth_attempts($source);
738
739 // Generate JWT token
740 // Use 0 as user_id to indicate API key authentication (system-level access)
741 $token = $this->generate_jwt_token(0);
742
743 if ($token === false) {
744 return new WP_Error(
745 'token_generation_failed',
746 'Failed to generate JWT token',
747 ['status' => 500]
748 );
749 }
750
751 return new WP_REST_Response([
752 'success' => true,
753 'token' => $token,
754 'token_type' => 'Bearer',
755 'expires_in' => self::JWT_EXPIRATION,
756 'expires_at' => time() + self::JWT_EXPIRATION,
757 'scope' => 'mcp:full_access'
758 ], 200);
759 }
760
761 /**
762 * Generate JWT token
763 *
764 * @param int $user_id WordPress user ID (0 for API key based tokens)
765 * @return string|false JWT token or false on failure
766 */
767 private function generate_jwt_token($user_id) {
768 $issued_at = time();
769 $expiration = $issued_at + self::JWT_EXPIRATION;
770
771 // JWT header
772 $header = [
773 'alg' => 'HS256',
774 'typ' => 'JWT'
775 ];
776
777 // Bind the token to the API key it is minted against. Without this
778 // claim a token outlives the key that authorised it and cannot be
779 // revoked before its expiry.
780 $fingerprint = $this->get_api_key_fingerprint();
781 if ($fingerprint === '') {
782 return false;
783 }
784
785 // JWT payload
786 // user_id = 0 indicates API key authentication (system-level access)
787 $payload = [
788 'sub' => $user_id === 0 ? 'api_key' : 'user:' . $user_id,
789 'user_id' => $user_id,
790 'iat' => $issued_at,
791 'exp' => $expiration,
792 'iss' => get_site_url(),
793 'akf' => $fingerprint,
794 'scope' => 'mcp:full_access'
795 ];
796
797 // Encode header and payload
798 $header_encoded = $this->base64_url_encode(json_encode($header));
799 $payload_encoded = $this->base64_url_encode(json_encode($payload));
800
801 // Create signature
802 $signature_input = $header_encoded . '.' . $payload_encoded;
803 $secret = $this->get_jwt_secret();
804 $signature = hash_hmac('sha256', $signature_input, $secret, true);
805 $signature_encoded = $this->base64_url_encode($signature);
806
807 // Create JWT token
808 $jwt = $header_encoded . '.' . $payload_encoded . '.' . $signature_encoded;
809
810 return $jwt;
811 }
812
813 /**
814 * Verify JWT token
815 *
816 * @param string $token JWT token
817 * @param bool $revoked Set true when the token was well-formed, correctly
818 * signed and unexpired but no longer matches the
819 * current API key. Reaching that state requires the
820 * signing secret, so it is a revocation to recover
821 * from rather than a credential guess to punish.
822 * @return array|false Decoded payload or false on failure
823 */
824 private function verify_jwt_token($token, &$revoked = false) {
825 $revoked = false;
826
827 // Split token into parts
828 $parts = explode('.', $token);
829
830 if (count($parts) !== 3) {
831 return false;
832 }
833
834 list($header_encoded, $payload_encoded, $signature_encoded) = $parts;
835
836 // Validate header: enforce alg=HS256 and typ=JWT to defeat
837 // algorithm-confusion attacks (e.g. "alg":"none").
838 $header_json = $this->base64_url_decode($header_encoded);
839 $header = json_decode($header_json, true);
840 if (!is_array($header)) {
841 return false;
842 }
843 if (!isset($header['alg']) || $header['alg'] !== 'HS256') {
844 return false;
845 }
846 if (!isset($header['typ']) || $header['typ'] !== 'JWT') {
847 return false;
848 }
849
850 // Verify signature
851 $signature_input = $header_encoded . '.' . $payload_encoded;
852 $secret = $this->get_jwt_secret();
853 $signature = hash_hmac('sha256', $signature_input, $secret, true);
854 $signature_expected = $this->base64_url_encode($signature);
855
856 if (!hash_equals($signature_expected, $signature_encoded)) {
857 return false;
858 }
859
860 // Decode payload
861 $payload_json = $this->base64_url_decode($payload_encoded);
862 $payload = json_decode($payload_json, true);
863
864 if (!$payload) {
865 return false;
866 }
867
868 // Check expiration — exp is mandatory; reject tokens missing or
869 // with a non-numeric / past expiry.
870 if (!isset($payload['exp']) || !is_numeric($payload['exp']) || (int) $payload['exp'] < time()) {
871 return false;
872 }
873
874 // Check issuer — iss is mandatory and must match this site.
875 if (!isset($payload['iss']) || $payload['iss'] !== get_site_url()) {
876 return false;
877 }
878
879 // Check user_id exists
880 if (!isset($payload['user_id'])) {
881 return false;
882 }
883
884 // Check the API key binding. Tokens are minted against an API key
885 // identity, so a rotated key must reject every token issued under the
886 // old one, and a disconnected site — which leaves no key at all —
887 // must reject them all. This runs before the user_id === 0 branch so
888 // API key tokens cannot skip it.
889 $fingerprint = $this->get_api_key_fingerprint();
890 if ($fingerprint === '') {
891 $revoked = true;
892 return false;
893 }
894 if (!isset($payload['akf']) || !is_string($payload['akf'])) {
895 $revoked = true;
896 return false;
897 }
898 if (!hash_equals($fingerprint, $payload['akf'])) {
899 $revoked = true;
900 return false;
901 }
902
903 // If user_id is 0, this is an API key-based token (system-level access)
904 // No need to validate user existence
905 if ($payload['user_id'] === 0) {
906 return $payload;
907 }
908
909 // For user-based tokens, verify user exists
910 $user = get_user_by('id', $payload['user_id']);
911 if (!$user) {
912 return false;
913 }
914
915 return $payload;
916 }
917
918 /**
919 * Get JWT secret key
920 *
921 * Uses a dedicated random secret stored in the WordPress options table.
922 * A new secret is generated on first use and persisted so that existing
923 * tokens remain valid across requests.
924 *
925 * @return string
926 */
927 private function get_jwt_secret() {
928 $secret = get_option('metasync_jwt_secret');
929 if (!empty($secret)) {
930 return $secret;
931 }
932 return $this->create_jwt_secret();
933 }
934
935 /**
936 * Persist the JWT secret on first use, first writer wins.
937 *
938 * Two requests arriving before the secret exists both see an empty option.
939 * add_option() and update_option() would each overwrite the other — core's
940 * add_option() is an INSERT ... ON DUPLICATE KEY UPDATE — leaving the
941 * loser's freshly signed token unverifiable. The insert below is a no-op
942 * once the row exists, so every request adopts the same secret.
943 *
944 * @return string
945 */
946 private function create_jwt_secret() {
947 global $wpdb;
948
949 $candidate = wp_generate_password(64, true, true);
950
951 $wpdb->query(
952 $wpdb->prepare(
953 "INSERT INTO {$wpdb->options} (option_name, option_value, autoload)"
954 . " VALUES (%s, %s, %s)"
955 . " ON DUPLICATE KEY UPDATE option_id = option_id",
956 'metasync_jwt_secret',
957 $candidate,
958 'no'
959 )
960 );
961
962 // Read the row back rather than trusting the candidate: on a race the
963 // winner's secret is the one that was persisted.
964 $stored = $wpdb->get_var(
965 $wpdb->prepare(
966 "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1",
967 'metasync_jwt_secret'
968 )
969 );
970
971 if (is_string($stored) && $stored !== '') {
972 // The options cache recorded a miss moments ago; drop it so later
973 // get_option() calls in this request see the persisted secret.
974 wp_cache_delete('metasync_jwt_secret', 'options');
975 wp_cache_delete('notoptions', 'options');
976 return $stored;
977 }
978
979 // The insert did not take for a reason other than the row already
980 // existing. Fall back to the original write path so the request can
981 // still mint a usable token.
982 update_option('metasync_jwt_secret', $candidate, false);
983 return $candidate;
984 }
985
986 /**
987 * Fingerprint the API key that MCP tokens are minted against.
988 *
989 * HMACs the key with the JWT secret so the claim carried inside a token is
990 * not an offline-crackable hash of the key itself.
991 *
992 * @return string Fingerprint, or an empty string when no API key is set.
993 */
994 private function get_api_key_fingerprint() {
995 $options = get_option('metasync_options', []);
996 $api_key = isset($options['general']['apikey']) ? $options['general']['apikey'] : '';
997
998 if (!is_string($api_key) || $api_key === '') {
999 return '';
1000 }
1001
1002 return hash_hmac('sha256', $api_key, $this->get_jwt_secret());
1003 }
1004
1005 /**
1006 * Resolve a stable, hard-to-forge key identifying the caller.
1007 *
1008 * REMOTE_ADDR alone is wrong behind a reverse proxy (every client
1009 * collapses into the proxy's bucket), but blindly trusting
1010 * CF-Connecting-IP / X-Forwarded-For is worse: any caller could mint a
1011 * fresh bucket per request, or poison somebody else's, simply by setting
1012 * a header. Forwarded headers are therefore honoured only when the TCP
1013 * peer is itself a configured trusted proxy, and the resolved client
1014 * address is bound to the proxy that vouched for it so two proxies can
1015 * never share a bucket.
1016 *
1017 * Sites behind Cloudflare/nginx/a load balancer should publish their
1018 * proxy ranges through the 'metasync_mcp_trusted_proxies' filter, e.g.
1019 *
1020 * add_filter( 'metasync_mcp_trusted_proxies', function ( $proxies ) {
1021 * $proxies[] = '173.245.48.0/20';
1022 * return $proxies;
1023 * } );
1024 *
1025 * The default is an empty list, i.e. only the un-forgeable TCP peer is
1026 * used. Combined with the site-wide bucket in check_rate_limit(), a
1027 * caller who rotates addresses still meets a ceiling.
1028 *
1029 * @return string Opaque source key (never empty).
1030 */
1031 private function get_auth_source_key() {
1032 $remote = isset($_SERVER['REMOTE_ADDR']) && is_string($_SERVER['REMOTE_ADDR'])
1033 ? trim($_SERVER['REMOTE_ADDR'])
1034 : '';
1035
1036 if (!filter_var($remote, FILTER_VALIDATE_IP)) {
1037 $remote = '';
1038 }
1039
1040 $trusted = apply_filters('metasync_mcp_trusted_proxies', []);
1041 if (!is_array($trusted)) {
1042 $trusted = [];
1043 }
1044
1045 if ($remote !== '' && !empty($trusted) && $this->is_trusted_proxy($remote, $trusted)) {
1046 $forwarded = $this->get_forwarded_ip();
1047 if ($forwarded !== '') {
1048 return 'fwd:' . $remote . '|' . $forwarded;
1049 }
1050 }
1051
1052 return $remote === '' ? 'unknown' : 'ip:' . $remote;
1053 }
1054
1055 /**
1056 * Read the client address a trusted proxy forwarded.
1057 *
1058 * Only called once the TCP peer has been confirmed as a trusted proxy.
1059 * X-Forwarded-For is walked right-to-left because the trusted proxy
1060 * appends to the right; anything further left may have been supplied by
1061 * the client.
1062 *
1063 * @return string Client IP, or '' when no usable value was forwarded.
1064 */
1065 private function get_forwarded_ip() {
1066 if (!empty($_SERVER['HTTP_CF_CONNECTING_IP']) && is_string($_SERVER['HTTP_CF_CONNECTING_IP'])) {
1067 $cf_ip = trim($_SERVER['HTTP_CF_CONNECTING_IP']);
1068 if (filter_var($cf_ip, FILTER_VALIDATE_IP)) {
1069 return $cf_ip;
1070 }
1071 }
1072
1073 if (!empty($_SERVER['HTTP_X_FORWARDED_FOR']) && is_string($_SERVER['HTTP_X_FORWARDED_FOR'])) {
1074 $candidates = array_reverse(explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']));
1075 foreach ($candidates as $candidate) {
1076 $candidate = trim($candidate);
1077 if ($candidate !== '' && filter_var($candidate, FILTER_VALIDATE_IP)) {
1078 return $candidate;
1079 }
1080 }
1081 }
1082
1083 if (!empty($_SERVER['HTTP_X_REAL_IP']) && is_string($_SERVER['HTTP_X_REAL_IP'])) {
1084 $real_ip = trim($_SERVER['HTTP_X_REAL_IP']);
1085 if (filter_var($real_ip, FILTER_VALIDATE_IP)) {
1086 return $real_ip;
1087 }
1088 }
1089
1090 return '';
1091 }
1092
1093 /**
1094 * Test whether an address belongs to the configured trusted-proxy set.
1095 *
1096 * @param string $ip Address to test (already validated).
1097 * @param array $proxies Plain addresses and/or CIDR ranges.
1098 * @return bool
1099 */
1100 private function is_trusted_proxy($ip, $proxies) {
1101 foreach ($proxies as $proxy) {
1102 if (is_string($proxy) && $proxy !== '' && $this->ip_in_cidr($ip, trim($proxy))) {
1103 return true;
1104 }
1105 }
1106
1107 return false;
1108 }
1109
1110 /**
1111 * Match an address against a plain address or CIDR range.
1112 *
1113 * Works for both IPv4 and IPv6 by comparing packed addresses.
1114 *
1115 * @param string $ip Address to test.
1116 * @param string $range Plain address or CIDR range.
1117 * @return bool
1118 */
1119 private function ip_in_cidr($ip, $range) {
1120 $ip_bin = @inet_pton($ip);
1121 if ($ip_bin === false) {
1122 return false;
1123 }
1124
1125 if (strpos($range, '/') === false) {
1126 $range_bin = @inet_pton($range);
1127 return $range_bin !== false && $range_bin === $ip_bin;
1128 }
1129
1130 list($subnet, $bits) = explode('/', $range, 2);
1131
1132 $subnet_bin = @inet_pton(trim($subnet));
1133 if ($subnet_bin === false || strlen($subnet_bin) !== strlen($ip_bin)) {
1134 // Unparseable subnet, or an IPv4/IPv6 family mismatch.
1135 return false;
1136 }
1137
1138 if (!is_numeric($bits)) {
1139 return false;
1140 }
1141
1142 $bits = (int) $bits;
1143 $max = strlen($ip_bin) * 8;
1144 if ($bits < 0 || $bits > $max) {
1145 return false;
1146 }
1147
1148 $whole_bytes = intdiv($bits, 8);
1149 $spare_bits = $bits % 8;
1150
1151 if ($whole_bytes > 0 && strncmp($ip_bin, $subnet_bin, $whole_bytes) !== 0) {
1152 return false;
1153 }
1154
1155 if ($spare_bits === 0) {
1156 return true;
1157 }
1158
1159 $mask = chr((0xFF << (8 - $spare_bits)) & 0xFF);
1160
1161 return ($ip_bin[$whole_bytes] & $mask) === ($subnet_bin[$whole_bytes] & $mask);
1162 }
1163
1164 /**
1165 * Resolve the failed-authentication limits, filterable per source.
1166 *
1167 * @param string $source Source key.
1168 * @return array{max:int,window:int,site_max:int,site_window:int}
1169 */
1170 private function get_auth_failure_limits($source) {
1171 $defaults = [
1172 'max' => self::AUTH_FAILURE_LIMIT,
1173 'window' => self::AUTH_FAILURE_WINDOW,
1174 'site_max' => self::AUTH_FAILURE_SITE_LIMIT,
1175 'site_window' => self::AUTH_FAILURE_WINDOW,
1176 ];
1177
1178 $limits = apply_filters('metasync_mcp_auth_failure_rate_limit', $defaults, $source);
1179 if (!is_array($limits)) {
1180 $limits = $defaults;
1181 }
1182
1183 return [
1184 'max' => max(1, isset($limits['max']) ? (int) $limits['max'] : $defaults['max']),
1185 'window' => max(1, isset($limits['window']) ? (int) $limits['window'] : $defaults['window']),
1186 'site_max' => max(1, isset($limits['site_max']) ? (int) $limits['site_max'] : $defaults['site_max']),
1187 'site_window' => max(1, isset($limits['site_window']) ? (int) $limits['site_window'] : $defaults['site_window']),
1188 ];
1189 }
1190
1191 /**
1192 * Transient name for a failed-authentication bucket.
1193 *
1194 * Hashing keeps the name within WordPress's length limit and keeps the
1195 * raw source address out of the options table.
1196 *
1197 * @param string $bucket Bucket identifier.
1198 * @return string
1199 */
1200 private function get_auth_bucket_key($bucket) {
1201 return self::AUTH_FAILURE_PREFIX . substr(hash('sha256', $bucket), 0, 40);
1202 }
1203
1204 /**
1205 * Read a failed-authentication bucket, treating an elapsed window as empty.
1206 *
1207 * @param string $bucket Bucket identifier.
1208 * @return array{attempts:int,expires_at:int}
1209 */
1210 private function read_auth_bucket($bucket) {
1211 $data = get_transient($this->get_auth_bucket_key($bucket));
1212 $now = time();
1213
1214 if (!is_array($data)
1215 || !isset($data['attempts'], $data['expires_at'])
1216 || (int) $data['expires_at'] <= $now
1217 ) {
1218 return ['attempts' => 0, 'expires_at' => 0];
1219 }
1220
1221 return [
1222 'attempts' => (int) $data['attempts'],
1223 'expires_at' => (int) $data['expires_at'],
1224 ];
1225 }
1226
1227 /**
1228 * Increment a failed-authentication bucket, escalating the lockout.
1229 *
1230 * Once the threshold is crossed the window is extended, doubling for each
1231 * further block of failures and capped at AUTH_FAILURE_MAX_LOCKOUT, so a
1232 * caller that keeps hammering waits progressively longer.
1233 *
1234 * @param string $bucket Bucket identifier.
1235 * @param int $limit Attempts allowed within the window.
1236 * @param int $window Base window in seconds.
1237 * @return void
1238 */
1239 private function bump_auth_bucket($bucket, $limit, $window) {
1240 $state = $this->read_auth_bucket($bucket);
1241 $now = time();
1242 $attempts = $state['attempts'] + 1;
1243 $expires = $state['expires_at'] > $now ? $state['expires_at'] : $now + $window;
1244
1245 if ($attempts >= $limit) {
1246 $tier = intdiv($attempts - $limit, $limit);
1247 $lockout = min($window * (1 << min($tier, 10)), self::AUTH_FAILURE_MAX_LOCKOUT);
1248 $expires = max($expires, $now + $lockout);
1249 }
1250
1251 set_transient(
1252 $this->get_auth_bucket_key($bucket),
1253 ['attempts' => $attempts, 'expires_at' => $expires],
1254 max(1, $expires - $now)
1255 );
1256 }
1257
1258 /**
1259 * Record a failed authentication attempt.
1260 *
1261 * Increments both the per-source bucket and the site-wide bucket. The
1262 * site-wide counter is the backstop for a caller that rotates its source
1263 * address faster than the per-source bucket can hold it.
1264 *
1265 * @param string $source Source key from get_auth_source_key().
1266 * @return void
1267 */
1268 private function record_failed_auth_attempt($source) {
1269 $limits = $this->get_auth_failure_limits($source);
1270
1271 $this->bump_auth_bucket($source, $limits['max'], $limits['window']);
1272 $this->bump_auth_bucket(
1273 self::AUTH_FAILURE_SITE_BUCKET,
1274 $limits['site_max'],
1275 $limits['site_window']
1276 );
1277
1278 if (defined('WP_DEBUG') && WP_DEBUG) {
1279 // Log the bucket digest only — never the presented credential.
1280 error_log(sprintf(
1281 'MetaSync MCP: failed authentication attempt (source=%s)',
1282 substr(hash('sha256', $source), 0, 12)
1283 ));
1284 }
1285 }
1286
1287 /**
1288 * Check whether the caller has exhausted the failed-authentication budget.
1289 *
1290 * When $record is true the attempt is written to both buckets *before*
1291 * the limit is evaluated, so the request that crosses the threshold is
1292 * itself counted rather than being handed out for free.
1293 *
1294 * The site-wide bucket is only consulted for AUTH_SCOPE_ALL. Recording,
1295 * when requested, always feeds both buckets regardless of scope.
1296 *
1297 * @param string $source Source key from get_auth_source_key().
1298 * @param bool $record Record this attempt before evaluating.
1299 * @param string $scope AUTH_SCOPE_ALL or AUTH_SCOPE_SOURCE.
1300 * @return true|WP_Error WP_Error carrying HTTP 429 when locked out.
1301 */
1302 private function check_rate_limit($source, $record = false, $scope = self::AUTH_SCOPE_ALL) {
1303 if ($record) {
1304 $this->record_failed_auth_attempt($source);
1305 }
1306
1307 $limits = $this->get_auth_failure_limits($source);
1308 $now = time();
1309
1310 $buckets = [[$source, $limits['max']]];
1311 if ($scope !== self::AUTH_SCOPE_SOURCE) {
1312 $buckets[] = [self::AUTH_FAILURE_SITE_BUCKET, $limits['site_max']];
1313 }
1314
1315 foreach ($buckets as $bucket) {
1316 list($name, $limit) = $bucket;
1317
1318 $state = $this->read_auth_bucket($name);
1319 if ($state['attempts'] < $limit) {
1320 continue;
1321 }
1322
1323 $retry_after = max(1, $state['expires_at'] - $now);
1324
1325 return new WP_Error(
1326 'too_many_requests',
1327 'Too many failed authentication attempts. Try again later.',
1328 [
1329 'status' => 429,
1330 'retry_after' => $retry_after,
1331 ]
1332 );
1333 }
1334
1335 return true;
1336 }
1337
1338 /**
1339 * Deepen an already-active lockout for a source that keeps knocking.
1340 *
1341 * Only the per-source bucket is advanced. Feeding the site-wide bucket
1342 * here would let a single blocked caller drive the site over its ceiling
1343 * and lock out everybody else, turning a brute-force defence into a
1344 * denial-of-service lever.
1345 *
1346 * @param string $source Source key from get_auth_source_key().
1347 * @return void
1348 */
1349 private function escalate_auth_lockout($source) {
1350 $limits = $this->get_auth_failure_limits($source);
1351 $this->bump_auth_bucket($source, $limits['max'], $limits['window']);
1352 }
1353
1354 /**
1355 * Clear the failed-authentication counter for a source.
1356 *
1357 * Called on successful authentication so a legitimate client is never
1358 * penalised for earlier failures. The site-wide counter is deliberately
1359 * left intact: one success must not wipe the evidence of a distributed
1360 * attack in progress.
1361 *
1362 * @param string $source Source key from get_auth_source_key().
1363 * @return void
1364 */
1365 private function clear_auth_attempts($source) {
1366 delete_transient($this->get_auth_bucket_key($source));
1367 }
1368
1369 /**
1370 * Base64 URL encode
1371 *
1372 * @param string $data Data to encode
1373 * @return string
1374 */
1375 private function base64_url_encode($data) {
1376 return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
1377 }
1378
1379 /**
1380 * Base64 URL decode
1381 *
1382 * @param string $data Data to decode
1383 * @return string
1384 */
1385 private function base64_url_decode($data) {
1386 return base64_decode(strtr($data, '-_', '+/'));
1387 }
1388 }
1389