getModule('options')->get('mcp', 'e_mcp')) { $this->logging = WaicFrame::_()->getModule('options')->get('mcp', 'mcp_logging'); $this->oauthEnabled = (bool) WaicFrame::_()->getModule('options')->get('mcp', 'mcp_oauth'); add_action('rest_api_init', array($this, 'restApiInit')); if ( $this->logging ) { add_action('init', array($this, 'logRequest'), 1); } // Intercept .well-known OAuth routes early (before WP processes them as pages) if ( $this->oauthEnabled ) { add_action('parse_request', array($this, 'interceptWellKnown'), 1); } } } public function logRequest() { if ( !$this->logging || empty( $_SERVER['REQUEST_METHOD'] ) || empty( $_SERVER['REQUEST_URI'] ) ) { return; } $uri = sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])); if (strpos( $uri, '/mcp/' ) === false && strpos( $uri, '/aiwu/' ) === false && strpos( $uri, '/.well-known/oauth' ) === false ) { return; } $ignore = array('/wp-admin/', '/wp-cron.php', '/favicon.ico'); foreach ($ignore as $pattern) { if (strpos($uri, $pattern) !== false) { return; } } $userAgent = WaicUtils::getUserAgent(); $ip = WaicUtils::getIP(); $uri = $this->sanitizeLogUri(WaicReq::getRequestUri()); if ($this->logging) { WaicFrame::_()->saveDebugLogging(array('uri' => $uri, 'agent' => $userAgent, 'ip' => $ip, 'method' => sanitize_text_field(wp_unslash($_SERVER['REQUEST_METHOD']))), false, 'MCP'); } } private function sanitizeLogUri( $uri ) { $uri = sanitize_text_field((string) $uri); if ('' === $uri || false === strpos($uri, '?')) { return $uri; } $parts = wp_parse_url($uri); if (!is_array($parts) || empty($parts['query'])) { return $uri; } parse_str($parts['query'], $query); $sensitive = array('token', 'access_token', 'refresh_token', 'code', 'client_secret', 'api_key', 'key'); foreach ($sensitive as $key) { if (array_key_exists($key, $query)) { $query[$key] = '[REDACTED]'; } } $out = isset($parts['path']) ? $parts['path'] : ''; $newQuery = http_build_query($query); if ('' !== $newQuery) { $out .= '?' . $newQuery; } if (!empty($parts['fragment'])) { $out .= '#' . rawurlencode($parts['fragment']); } return '' === $out ? $uri : $out; } /** * Intercept .well-known OAuth discovery requests before WP processes them. * Handles: * GET /.well-known/oauth-protected-resource (RFC 9728) * GET /.well-known/oauth-authorization-server (RFC 8414) */ public function interceptWellKnown( $wp ) { if ( empty( $_SERVER['REQUEST_URI'] ) ) { return; } $uri = wp_parse_url(sanitize_text_field(wp_unslash($_SERVER['REQUEST_URI'])), PHP_URL_PATH ); if ( false === $uri ) { return; } $uri = rtrim( $uri, '/' ); if ( '/.well-known/oauth-protected-resource' === $uri ) { $this->serveProtectedResourceMetadata(); } if ( '/.well-known/oauth-authorization-server' === $uri ) { $this->serveAuthServerMetadata(); } } /** * RFC 9728 – Protected Resource Metadata. * Tells the MCP client which Authorization Server to use. */ private function serveProtectedResourceMetadata() { $siteUrl = home_url(); $meta = array( 'resource' => rest_url( $this->namespace . '/sse' ), 'authorization_servers' => array( $siteUrl ), 'bearer_methods_supported' => array( 'header' ), 'scopes_supported' => array( 'mcp' ), ); if ( $this->logging ) { WaicFrame::_()->saveDebugLogging( 'Serving /.well-known/oauth-protected-resource', false, 'MCP' ); } $this->sendJson( $meta ); } /** * RFC 8414 – Authorization Server Metadata. * Provides endpoints for the OAuth 2.1 flow. */ private function serveAuthServerMetadata() { $siteUrl = home_url(); $meta = array( 'issuer' => $siteUrl, 'authorization_endpoint' => rest_url( $this->namespace . '/oauth/authorize' ), 'token_endpoint' => rest_url( $this->namespace . '/oauth/token' ), 'registration_endpoint' => rest_url( $this->namespace . '/oauth/register' ), 'response_types_supported' => array( 'code' ), 'grant_types_supported' => array( 'authorization_code', 'refresh_token' ), 'token_endpoint_auth_methods_supported' => array( 'none' ), 'code_challenge_methods_supported' => array( 'S256' ), 'scopes_supported' => array( 'mcp' ), ); if ( $this->logging ) { WaicFrame::_()->saveDebugLogging( 'Serving /.well-known/oauth-authorization-server', false, 'MCP' ); } $this->sendJson( $meta ); } /** * Send a JSON response and exit (for .well-known handlers). */ private function sendJson( $data, $code = 200 ) { status_header( $code ); header( 'Content-Type: application/json; charset=utf-8' ); header( 'Cache-Control: no-store' ); header( 'Access-Control-Allow-Origin: *' ); echo wp_json_encode( $data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); exit; } public function restApiInit() { if ($this->mcpToken === null) { $optionsModel = WaicFrame::_()->getModule('options')->getModel(); $this->mcpToken = $optionsModel->get('mcp', 'mcp_token'); $this->mcpTokenUserId = absint($optionsModel->get('mcp', 'mcp_token_user_id')); } if ((!empty($this->mcpToken) || $this->oauthEnabled) && !$this->addedFilter) { WaicDispatcher::addFilter('allow_mcp', array($this, 'authViaBeaberToken'), 10, 2); $this->addedFilter = true; } register_rest_route($this->namespace, '/sse', array( 'methods' => 'GET', 'callback' => array($this, 'handleSSE'), 'permission_callback' => function( $request ) { return $this->canAccessMCP($request); }, )); register_rest_route($this->namespace, '/sse', array( 'methods' => 'POST', 'callback' => array($this, 'handleSSE'), 'permission_callback' => function( $request ) { return $this->canAccessMCP($request); }, )); register_rest_route($this->namespace, '/messages', array( 'methods' => 'POST', 'callback' => array($this, 'handleMessage'), 'permission_callback' => function( $request ) { return $this->canAccessMCP($request); }, )); //WaicDispatcher::applyFilters('mcp_tools', $tools); //add_filter( 'mwai_mcp_tools', [ $this, 'register_rest_tools' ] ); WaicDispatcher::addFilter('mcp_callback', array($this, 'handleCallback'), 10, 4); // OAuth 2.1 endpoints (only when OAuth auth mode is enabled) if ( $this->oauthEnabled ) { register_rest_route($this->namespace, '/oauth/authorize', array( 'methods' => 'GET', 'callback' => array($this, 'oauthAuthorize'), 'permission_callback' => array($this, 'oauthPublicEndpointPermission'), )); register_rest_route($this->namespace, '/oauth/token', array( 'methods' => 'POST', 'callback' => array($this, 'oauthToken'), 'permission_callback' => array($this, 'oauthPublicEndpointPermission'), )); register_rest_route($this->namespace, '/oauth/register', array( 'methods' => 'POST', 'callback' => array($this, 'oauthRegister'), 'permission_callback' => array($this, 'oauthPublicEndpointPermission'), )); } } public function oauthPublicEndpointPermission( $request ) { return (bool) $this->oauthEnabled; } public function canAccessMCP( $request ) { //return true; $isAdmin = current_user_can('manage_options'); return WaicDispatcher::applyFilters('allow_mcp', $isAdmin, $request); } public function handleCallback( $result, string $tool, array $args, int $id ) { if (!empty($result)) { return $result; } $tools = $this->getModel()->getTools(); if (!isset($tools[$tool])) { WaicFrame::_()->saveDebugLogging('Tool not found ' . $tool, false, 'MCP'); return $result; } return $this->getModel()->dispatchTool($tool, $args, $id); } public function authViaBeaberToken($allow, $request) { $hdr = $request->get_header('Authorization'); if (!$hdr) { $token = sanitize_text_field($request->get_param('token')); if ($token) { return $this->authenticateMcpBearerToken($token, $request, 'query'); } if ($this->logging) { WaicFrame::_()->saveDebugLogging('No authorization header provided.', false, 'MCP'); } return (!empty($this->mcpToken) || $this->oauthEnabled) ? false : $allow; } if ($hdr && preg_match('/Bearer\s+(.+)/i', $hdr, $m)) { $token = trim($m[1]); return $this->authenticateMcpBearerToken($token, $request, 'header'); } if (!empty($this->mcpToken) || $this->oauthEnabled) { return false; } return $allow; } private function authenticateMcpBearerToken( $token, $request, $source ) { if (is_string($this->mcpToken) && '' !== $this->mcpToken && hash_equals($this->mcpToken, $token)) { if ($this->restoreDirectMcpTokenUser()) { if ($this->logging && $request && strpos($request->get_route(), '/sse') !== false) { WaicFrame::_()->saveDebugLogging('Auth OK (direct token, ' . $source . ')', false, 'MCP'); } return true; } if ($this->logging) { WaicFrame::_()->saveDebugLogging('Direct MCP token rejected: missing or unauthorized owner.', false, 'MCP'); } return false; } if ($this->oauthEnabled) { $tokenData = $this->validateOAuthAccessToken($token); if ($tokenData && $this->restoreOAuthAccessTokenUser($tokenData)) { if ($this->logging) { WaicFrame::_()->saveDebugLogging('Auth OK (OAuth, ' . $source . ')', false, 'MCP'); } return true; } } if ($this->logging) { WaicFrame::_()->saveDebugLogging('Bearer token invalid', false, 'MCP'); } return false; } private function restoreDirectMcpTokenUser() { $userId = absint($this->mcpTokenUserId); if (!$userId) { $optionsModel = WaicFrame::_()->getModule('options')->getModel(); $this->mcpTokenUserId = absint($optionsModel->get('mcp', 'mcp_token_user_id')); $userId = absint($this->mcpTokenUserId); } return $this->restoreMcpUser($userId, $this->mcpRequiredCapability()); } private function restoreOAuthAccessTokenUser( $tokenData ) { $userId = isset($tokenData['user_id']) ? absint($tokenData['user_id']) : 0; return $this->restoreMcpUser($userId, $this->oauthRequiredCapability()); } private function restoreMcpUser( $userId, $capability ) { if (!$userId) { return false; } $user = get_user_by('id', $userId); if (!$user) { return false; } wp_set_current_user($userId); return current_user_can($capability); } private function userCanUseMcp( $userId, $capability ) { if (!$userId) { return false; } $user = get_user_by('id', $userId); if (!$user) { return false; } return user_can($user, $capability); } private function mcpRequiredCapability() { $capability = WaicDispatcher::applyFilters('mcp_required_capability', 'manage_options'); return is_string($capability) && $capability ? $capability : 'manage_options'; } private function oauthRequiredCapability() { $capability = WaicDispatcher::applyFilters('mcp_oauth_required_capability', $this->mcpRequiredCapability()); return is_string($capability) && $capability ? $capability : 'manage_options'; } private function getSSEid($req) { $last = $req ? $req->get_header('last-event-id') : ''; return empty($last) ? str_replace('-', '', wp_generate_uuid4()) : $last; } // Handle POST request with JSON-RPC body (Direct MCP client behavior) // Claude.ai requests directly to the SSE endpoint instead of establishing an SSE connection first. This is non-standard but we need to support it. // Expected flow: GET /sse (establish stream) → POST /messages (send JSON-RPC) // Actual flow: POST /sse with JSON-RPC body → expects immediate JSON response public function handleSSE( WP_REST_Request $request ) { $body = $request->get_body(); if ($request->get_method() === 'POST' && !empty($body)) { $data = json_decode($body, true); if ($data && isset($data['method'])) { return $this->handleDirectJsonRPC($request, $data); } } @ini_set('zlib.output_compression', '0'); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged @ini_set('output_buffering', '0'); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged @ini_set('implicit_flush', '1'); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged if (function_exists('ob_implicit_flush')) { ob_implicit_flush( true ); } header('Content-Type: text/event-stream'); header('Cache-Control: no-cache'); header('X-Accel-Buffering: no'); header('Connection: keep-alive'); header('Access-Control-Allow-Origin: *'); header('Access-Control-Allow-Headers: Authorization, Content-Type'); while (ob_get_level()) { ob_end_flush(); } $this->sessionID = $this->getSSEid($request); $this->lastAction = time(); //echo "id: {$this->sessionID}\n\n"; //flush(); $msgUri = sprintf('%s/messages?session_id=%s', rest_url($this->namespace), $this->sessionID); if (!empty($this->mcpToken)) { $msgUri .= '&token=' . $this->mcpToken; } $this->reply('endpoint', $msgUri, 'text'); if ($this->logging) { WaicFrame::_()->saveDebugLogging('SSE connected (' . substr( $this->sessionID, 0, 8 ) . '...)', false, 'MCP'); } while (true) { $maxTime = $this->logging ? 60 : 60 * 5; $idle = ( time() - $this->lastAction ) >= $maxTime; if (connection_aborted() || $idle) { $this->reply('bye'); if ($this->logging) { WaicFrame::_()->saveDebugLogging('SSE closed (' . ( $idle ? 'idle' : 'abort' ) . ')', false, 'MCP'); } break; } foreach ($this->fetchMessages($this->sessionID) as $p) { if (isset($p['method']) && 'aiwu/kill' === $p['method']) { if ($this->logging) { WaicFrame::_()->saveDebugLogging('Kill signal - terminating', false, 'MCP'); } $this->reply('bye'); exit; } //WaicFrame::_()->saveDebugLogging('message', false, 'MCP'); //WaicFrame::_()->saveDebugLogging($p, false, 'MCP'); $this->reply('message', $p); } usleep(200000); if (time() - $this->lastAction > 10) $this->reply('heartbeat', ['status' => 'alive']); } exit; } private function reply( string $event, $data = null, string $enc = 'json' ) { if ('bye' === $event) { echo "event: bye\ndata: \n\n"; if (ob_get_level()) { ob_end_flush(); } flush(); $this->lastAction = time(); if ($this->logging) { WaicFrame::_()->saveDebugLogging('Clean disconnection (' . substr( $this->sessionID, 0, 8 ) . '...)', false, 'MCP'); } return; } if ('json' === $enc && null === $data) { if ($this->logging) { WaicFrame::_()->saveDebugLogging('no data for ' . $event, false, 'MCP'); } return; } echo 'event: ' . esc_html($event) . "\n"; if ('json' === $enc) { $data = null === $data ? '{}' : str_replace('[]', '{}', wp_json_encode($data, JSON_UNESCAPED_UNICODE)); } echo 'data: ' . esc_html($data) . "\n\n"; if (ob_get_level()) { ob_end_flush(); } //ob_flush(); flush(); $this->lastAction = time(); if ($this->logging && 'endpoint' === $event) { WaicFrame::_()->saveDebugLogging('SSE endpoint ready', false, 'MCP'); } } private function log( $msg ) { if ($this->logging) { if (strpos($msg, 'queued') === false && strpos($msg, 'flush') === false) { WaicFrame::_()->saveDebugLogging('MCP: ' . $msg, false, 'MCP'); } } } private function rpcError( $id, int $code, string $msg, $extra = null ): array { $err = array('code' => $code, 'message' => $msg); if (!is_null($extra)) { $err['data'] = $extra; } return array('jsonrpc' => '2.0', 'id' => $id, 'error' => $err); } private function queueError( $sess, $id, int $code, string $msg, $extra = null ): void { $this->storeMessage($sess, $this->rpcError($id, $code, $msg, $extra)); } private function formatToolResult( $result ): array { if (is_string($result)) { return array( 'content' => array( array( 'type' => 'text', 'text' => $result, ), ), ); } if (is_array($result) && isset($result['content'])) { return $result; } if (is_array($result)) { return array( 'content' => array( array( 'type' => 'text', 'text' => wp_json_encode($result, JSON_PRETTY_PRINT), ), ), 'data' => $result, ); } return array( 'content' => array( array( 'type' => 'text', 'text' => (string) $result, ), ), ); } //Claude's MCP client (via Anthropic API) sends JSON-RPC requests directly to the SSE endpoint //as POST requests, rather than following the typical SSE flow: //- Normal flow: GET /sse → establish SSE stream → POST /messages for JSON-RPC //- Claude's flow: POST /sse with JSON-RPC body → expect immediate JSON response private function handleDirectJsonRPC( WP_REST_Request $request, $data ) { $id = isset($data['id']) ? $data['id'] : null; $method = isset($data['method']) ? $data['method'] : null; if (json_last_error() !== JSON_ERROR_NONE) { return new WP_REST_Response(array( 'jsonrpc' => '2.0', 'id' => null, 'error' => array('code' => -32700, 'message' => 'Parse error: invalid JSON'), ), 200); } if (!is_array($data) || !$method) { return new WP_REST_Response(array( 'jsonrpc' => '2.0', 'id' => $id, 'error' => array('code' => -32600, 'message' => 'Invalid Request'), ), 200); } try { $reply = null; switch ($method) { case 'initialize': $params = WaicUtils::getArrayValue($data, 'params', array(), 2); $reqVersion = WaicUtils::getArrayValue($params, 'protocolVersion', null); $clientInfo = WaicUtils::getArrayValue($params, 'clientInfo', false); if ($this->logging && is_array($clientInfo)) { WaicFrame::_()->saveDebugLogging('Client: ' . WaicUtils::getArrayValue($clientInfo, 'name', 'unknown') . ' v:' . WaicUtils::getArrayValue($clientInfo, 'version', 'unknown'), false, 'MCP'); } if ($this->logging && $reqVersion && $reqVersion !== $this->protocolVersion ) { WaicFrame::_()->saveDebugLogging('!Client requested protocol version is ' . $reqVersion . '. Supported only ' . $this->protocolVersion, false, 'MCP'); } $reply = array( 'jsonrpc' => '2.0', 'id' => $id, 'result' => array( 'protocolVersion' => $this->protocolVersion, 'serverInfo' => (object) array( 'name' => get_bloginfo('name') . ' MCP', 'version' => $this->serverVersion, ), 'capabilities' => array( 'tools' => array('listChanged' => true), 'prompts' => array('subscribe' => false, 'listChanged' => false), 'resources' => array('subscribe' => false, 'listChanged' => false), ), ), ); break; case 'tools/list': $tools = $this->getToolsList(); $reply = array( 'jsonrpc' => '2.0', 'id' => $id, 'result' => array('tools' => $tools), ); if ($this->logging) { WaicFrame::_()->saveDebugLogging('Returning ' . count( $tools ) . ' tools.', false, 'MCP'); } break; case 'tools/call': $params = WaicUtils::getArrayValue($data, 'params', array(), 2); $tool = WaicUtils::getArrayValue($params, 'name'); $arguments = WaicUtils::getArrayValue($params, 'arguments', array(), 2); $reply = $this->executeTool($tool, $arguments, $id); break; case 'notifications/initialized': $reply = array( 'jsonrpc' => '2.0', 'id' => $id, 'method' => 'tools/listChanged', ); //$this->reply('tools/listChanged', array('jsonrpc' => '2.0', 'method' => 'tools/listChanged', 'params' => array())); //return new WP_REST_Response(null, 204); break; case 'resources/list': $reply = array( 'jsonrpc' => '2.0', 'id' => $id, 'result' => array('resources' => array()), ); break; case 'prompts/list': $reply = array( 'jsonrpc' => '2.0', 'id' => $id, 'result' => array('prompts' => array()), ); break; default: if (is_null($id) && strpos($method, 'notifications/') === 0) { if ($this->logging) { WaicFrame::_()->saveDebugLogging('Notification received: ' . $method, false, 'MCP'); } return new WP_REST_Response(null, 204); } $reply = array( 'jsonrpc' => '2.0', 'id' => $id, 'error' => array('code' => -44001, 'message' => "Method not found: {$method}"), ); } $response = new WP_REST_Response($reply, 200); $response->set_headers(array('Content-Type' => 'application/json')); return $response; } catch ( Exception $e ) { $response = new WP_REST_Response(array( 'jsonrpc' => '2.0', 'id' => $id, 'error' => array('code' => -44000, 'message' => 'Internal error', 'data' => $e->getMessage()) ), 200); $response->set_headers(array('Content-Type' => 'application/json')); return $response; } } public function handleMessage( WP_REST_Request $request ) { $sess = sanitize_text_field($request->get_param('session_id')); $body = $request->get_body(); $data = json_decode($body, true); if ( $this->logging && $data && isset($data['method'])) { $method = $data['method']; if (!in_array($method, array('notifications/initialized', 'notifications/cancelled'))) { WaicFrame::_()->saveDebugLogging('Handle method: ' . $method, false, 'MCP'); } } if (json_last_error() !== JSON_ERROR_NONE) { $this->queueError($sess, null, -44500, 'Parse error: invalid JSON'); return new WP_REST_Response(null, 204); } if (!is_array($data)) { $this->queueError($sess, null, -44800, 'Invalid Request'); return new WP_REST_Response(null, 204); } $id = isset($data['id']) ? $data['id'] : null; $method = WaicUtils::getArrayValue($data, 'method', null); if ('initialized' === $method) { return new WP_REST_Response(null, 204); } if ('aiwu/kill' === $method) { $this->storeMessage($sess, array('jsonrpc' => '2.0', 'method' => 'aiwu/kill')); usleep( 100000 ); return new WP_REST_Response(null, 204); } if (is_null($id) && !is_null($method)) { return new WP_REST_Response(null, 204); } if (!$method) { $this->queueError($sess, $id, -32900, 'Invalid Request: method missing'); return new WP_REST_Response(null, 204); } try { $reply = null; switch ($method) { case 'initialize': $params = WaicUtils::getArrayValue($data, 'params', array(), 2); $requestedVersion = WaicUtils::getArrayValue($params, 'protocolVersion', null); $clientInfo = WaicUtils::getArrayValue($params, 'clientInfo', null); if ($this->logging && is_array($clientInfo)) { WaicFrame::_()->saveDebugLogging('Client: ' . WaicUtils::getArrayValue($clientInfo, 'name', 'unknown') . ' v' . WaicUtils::getArrayValue($clientInfo, 'version', 'unknown'), false, 'MCP'); } if ($requestedVersion && $requestedVersion !== $this->protocolVersion) { if ($this->logging) { WaicFrame::_()->saveDebugLogging('Client requested protocol version is ' . $requestedVersion . '. Supported only ' . $this->protocolVersion, false, 'MCP'); } } $reply = array( 'jsonrpc' => '2.0', 'id' => $id, 'result' => array( 'protocolVersion' => $this->protocolVersion, 'serverInfo' => (object) array( 'name' => get_bloginfo( 'name' ) . ' MCP', 'version' => $this->serverVersion, ), 'capabilities' => array( 'tools' => array('listChanged' => true), 'prompts' => array('subscribe' => false, 'listChanged' => false), 'resources' => array('subscribe' => false, 'listChanged' => false), ), ), ); break; case 'tools/list': $tools = $this->getToolsList(); $reply = array( 'jsonrpc' => '2.0', 'id' => $id, 'result' => array('tools' => $tools), ); break; case 'resources/list': $reply = array( 'jsonrpc' => '2.0', 'id' => $id, 'result' => array('resources' => $this->getResourcesList()), ); break; case 'prompts/list': $reply = array( 'jsonrpc' => '2.0', 'id' => $id, 'result' => array('prompts' => $this->getPromptsList()), ); break; case 'tools/call': $params = WaicUtils::getArrayValue($data, 'params', array(), 2); $tool = WaicUtils::getArrayValue($params, 'name'); $arguments = WaicUtils::getArrayValue($params, 'arguments', array(), 2); $reply = $this->executeTool($tool, $arguments, $id); break; default: $reply = $this->rpcError($id, -45601, "Method not found: {$method}"); } if ($reply) { $this->storeMessage($sess, $reply); } } catch ( Exception $e ) { $this->queueError($sess, $id, -45603, 'Internal error', $e->getMessage() ); } return new WP_REST_Response(null, 204); } public function getToolsList() { return $this->getModel()->getToolsList(); } private function getResourcesList() { return array(); } private function getPromptsList() { return array(); } private function executeTool( $tool, $args, $id ) { try { if ($this->logging) { WaicFrame::_()->saveDebugLogging('Tool: ' . $tool, false, 'MCP'); if (!empty($args)) { WaicFrame::_()->saveDebugLogging($args, false, 'MCP'); } } $filtered = WaicDispatcher::applyFilters('mcp_callback', null, $tool, $args, $id, $this); if (!is_null($filtered)) { if (is_array($filtered) && isset($filtered['jsonrpc']) && isset($filtered['id'])) { return $filtered; } return array( 'jsonrpc' => '2.0', 'id' => $id, 'result' => $this->formatToolResult($filtered), ); } throw new Exception("Unknown tool: {$tool}"); } catch ( Exception $e ) { return $this->rpcError( $id, -44003, $e->getMessage() ); } } private function transientKey( $sess, $id ) { return "{$this->queueKey}_{$sess}_{$id}"; } private function storeMessage( $sess, $payload ) { if (!$sess) { return; } $idKey = array_key_exists('id', $payload) ? ( isset($payload['id']) ? $payload['id'] : 'NULL' ) : 'N/A'; set_transient($this->transientKey($sess, $idKey), $payload, 30); $this->log("queued #{$idKey}"); } private function fetchMessages( $sess ) { global $wpdb; $like = $wpdb->esc_like( '_transient_' . "{$this->queueKey}_{$sess}_" ) . '%'; // phpcs:ignore WordPress.DB.DirectDatabaseQuery $rows = $wpdb->get_results( $wpdb->prepare("SELECT option_name, option_value FROM {$wpdb->options} WHERE option_name LIKE %s", $like), ARRAY_A ); $msgs = array(); foreach ($rows as $r) { $msgs[] = maybe_unserialize($r['option_value']); delete_option( $r['option_name'] ); } usort($msgs, function( $a, $b ) { $aId = isset($a['id']) ? $a['id'] : 0; $bId = isset($b['id']) ? $b['id'] : 0; if ($aId == $bId) { return 0; } return ($aId < $bId) ? -1 : 1; }); if ($msgs) { $this->log( 'flush ' . count( $msgs ) . ' msg(s)' ); } return $msgs; } // ────────────────────────────────────────────────────────────── // OAuth 2.1 Endpoints (RFC 8414, RFC 9728, RFC 7591, PKCE) // ────────────────────────────────────────────────────────────── /** * Validate an OAuth-issued access token stored in transients. */ private function validateOAuthAccessToken( $token ) { if ( empty( $token ) || strlen( $token ) < 32 ) { return false; } $key = 'aiwu_oauth_at_' . hash( 'sha256', $token ); $data = get_transient( $key ); if ( ! $data || ! is_array( $data ) ) { return false; } if ( empty( $data['user_id'] ) || empty( $data['client_id'] ) ) { delete_transient( $key ); return false; } if ( ! empty( $data['expires'] ) && time() > $data['expires'] ) { delete_transient( $key ); return false; } if ( ! $this->userCanUseMcp( absint( $data['user_id'] ), $this->oauthRequiredCapability() ) ) { return false; } return $data; } /** * Store an OAuth access token in transients. */ private function storeOAuthAccessToken( $token, $userId, $clientId, $scope, $ttl = 3600 ) { $key = 'aiwu_oauth_at_' . hash( 'sha256', $token ); set_transient( $key, array( 'user_id' => absint( $userId ), 'client_id' => sanitize_text_field( $clientId ), 'scope' => sanitize_text_field( $scope ), 'created' => time(), 'expires' => time() + $ttl, ), $ttl + 60 ); } /** * Store an OAuth refresh token in transients. */ private function storeOAuthRefreshToken( $refreshToken, $accessToken, $userId, $clientId, $scope, $ttl = 86400 ) { $key = 'aiwu_oauth_rt_' . hash( 'sha256', $refreshToken ); set_transient( $key, array( 'access_token' => $accessToken, 'user_id' => absint( $userId ), 'client_id' => sanitize_text_field( $clientId ), 'scope' => sanitize_text_field( $scope ), 'created' => time(), 'expires' => time() + $ttl, ), $ttl + 60 ); } /** * Validate and consume a refresh token. Returns access_token data or false. */ private function consumeRefreshToken( $refreshToken ) { $key = 'aiwu_oauth_rt_' . hash( 'sha256', $refreshToken ); $data = get_transient( $key ); if ( ! $data || ! is_array( $data ) ) { return false; } if ( empty( $data['user_id'] ) || empty( $data['client_id'] ) ) { delete_transient( $key ); return false; } if ( ! empty( $data['expires'] ) && time() > $data['expires'] ) { delete_transient( $key ); return false; } // Rotate: delete old refresh token delete_transient( $key ); return $data; } /** * Generate a cryptographically random token string. */ private function generateToken( $length = 48 ) { return bin2hex( random_bytes( $length / 2 ) ); } /** * Verify PKCE code_verifier against stored code_challenge (S256). */ private function verifyPKCE( $verifier, $challenge ) { $computed = rtrim( strtr( base64_encode( hash( 'sha256', $verifier, true ) ), '+/', '-_' ), '=' ); return hash_equals( $challenge, $computed ); } private function getOAuthClientData( $clientId ) { if ( empty( $clientId ) || ! is_string( $clientId ) ) { return false; } $clientData = get_transient( 'aiwu_oauth_client_' . sanitize_text_field( $clientId ) ); if ( ! $clientData || ! is_array( $clientData ) ) { return false; } if ( empty( $clientData['client_id'] ) || ! is_string( $clientData['client_id'] ) || ! hash_equals( $clientData['client_id'], $clientId ) ) { return false; } if ( empty( $clientData['redirect_uris'] ) || ! is_array( $clientData['redirect_uris'] ) ) { return false; } return $clientData; } private function normalizeOAuthRedirectUri( $uri ) { $clean = esc_url_raw( $uri ); if ( empty( $clean ) ) { return ''; } $parts = wp_parse_url( $clean ); if ( ! is_array( $parts ) || empty( $parts['scheme'] ) || empty( $parts['host'] ) ) { return ''; } if ( ! empty( $parts['fragment'] ) || ! empty( $parts['user'] ) || ! empty( $parts['pass'] ) ) { return ''; } $scheme = strtolower( $parts['scheme'] ); $host = strtolower( $parts['host'] ); if ( 'https' === $scheme ) { return $clean; } if ( 'http' === $scheme && $this->isLoopbackOAuthHost( $host ) ) { return $clean; } return ''; } private function isLoopbackOAuthHost( $host ) { return in_array( $host, array( 'localhost', '127.0.0.1', '::1' ), true ); } private function isRegisteredOAuthRedirectUri( $redirectUri, $clientData ) { if ( empty( $redirectUri ) || empty( $clientData['redirect_uris'] ) || ! is_array( $clientData['redirect_uris'] ) ) { return false; } return in_array( $redirectUri, $clientData['redirect_uris'], true ); } private function normalizeOAuthScope( $scope ) { $scope = trim( (string) $scope ); if ( '' === $scope ) { return 'mcp'; } $parts = preg_split( '/\s+/', $scope ); foreach ( $parts as $part ) { if ( 'mcp' !== $part ) { return false; } } return 'mcp'; } private function validPKCEVerifier( $verifier ) { return is_string( $verifier ) && preg_match( '/^[A-Za-z0-9._~-]{43,128}$/', $verifier ); } /** * POST /oauth/register — Dynamic Client Registration (RFC 7591). * Claude.ai sends client_name, redirect_uris, etc. * We issue a client_id (no secret needed for public clients). */ public function oauthRegister( WP_REST_Request $request ) { if ( ! $this->oauthEnabled ) { return new WP_REST_Response( array( 'error' => 'temporarily_unavailable' ), 404 ); } $body = json_decode( $request->get_body(), true ); if ( ! is_array( $body ) ) { return new WP_REST_Response( array( 'error' => 'invalid_request' ), 400 ); } $clientName = sanitize_text_field( isset( $body['client_name'] ) ? $body['client_name'] : 'MCP Client' ); $clientName = $clientName ? substr( $clientName, 0, 120 ) : 'MCP Client'; $redirectUris = isset( $body['redirect_uris'] ) && is_array( $body['redirect_uris'] ) ? $body['redirect_uris'] : array(); if ( empty( $redirectUris ) ) { return new WP_REST_Response( array( 'error' => 'invalid_client_metadata', 'error_description' => 'redirect_uris is required' ), 400 ); } $cleanUris = array(); foreach ( $redirectUris as $uri ) { $clean = $this->normalizeOAuthRedirectUri( $uri ); if ( $clean ) { $cleanUris[] = $clean; } } $cleanUris = array_values( array_unique( $cleanUris ) ); if ( empty( $cleanUris ) ) { return new WP_REST_Response( array( 'error' => 'invalid_redirect_uri', 'error_description' => 'At least one valid HTTPS redirect_uri is required' ), 400 ); } $clientId = 'aiwu_' . $this->generateToken( 24 ); // Store client registration (7 days TTL) set_transient( 'aiwu_oauth_client_' . $clientId, array( 'client_id' => $clientId, 'client_name' => $clientName, 'redirect_uris' => $cleanUris, 'created' => time(), ), 7 * DAY_IN_SECONDS ); if ( $this->logging ) { WaicFrame::_()->saveDebugLogging( 'DCR: registered client "' . $clientName . '" → ' . $clientId, false, 'MCP' ); } $response = new WP_REST_Response( array( 'client_id' => $clientId, 'client_name' => $clientName, 'redirect_uris' => $cleanUris, 'grant_types' => array( 'authorization_code', 'refresh_token' ), 'response_types' => array( 'code' ), 'token_endpoint_auth_method' => 'none', ), 201 ); $response->set_headers( array( 'Cache-Control' => 'no-store' ) ); return $response; } /** * GET /oauth/authorize — Authorization Endpoint. * * Claude.ai redirects the user here. We show a simple consent page. * On approval, we redirect back with an authorization code. * * Query params: client_id, redirect_uri, state, scope, * code_challenge, code_challenge_method, response_type */ public function oauthAuthorize( WP_REST_Request $request ) { if ( ! $this->oauthEnabled ) { return new WP_REST_Response( array( 'error' => 'temporarily_unavailable' ), 404 ); } $userId = $this->restoreOAuthBrowserUser(); if ( ! $userId ) { $this->redirectToOAuthLogin(); } if ( ! current_user_can( $this->oauthRequiredCapability() ) ) { return new WP_REST_Response( array( 'error' => 'access_denied', 'error_description' => 'You are not allowed to authorize MCP OAuth access.' ), 403 ); } $clientId = sanitize_text_field( $request->get_param( 'client_id' ) ); $redirectUri = $this->normalizeOAuthRedirectUri( $request->get_param( 'redirect_uri' ) ); $state = sanitize_text_field( $request->get_param( 'state' ) ); $scope = $this->normalizeOAuthScope( $request->get_param( 'scope' ) ); $codeChallenge = sanitize_text_field( $request->get_param( 'code_challenge' ) ); $codeChallengeMethod = sanitize_text_field( $request->get_param( 'code_challenge_method' ) ); $responseType = sanitize_text_field( $request->get_param( 'response_type' ) ); $clientData = $this->getOAuthClientData( $clientId ); // Validate required params if ( 'code' !== $responseType || empty( $clientId ) || empty( $redirectUri ) || ! $clientData ) { return new WP_REST_Response( array( 'error' => 'invalid_request', 'error_description' => 'response_type, client_id and a registered redirect_uri are required' ), 400 ); } if ( ! $this->isRegisteredOAuthRedirectUri( $redirectUri, $clientData ) ) { return new WP_REST_Response( array( 'error' => 'invalid_request', 'error_description' => 'redirect_uri is not registered for this client' ), 400 ); } if ( false === $scope ) { return new WP_REST_Response( array( 'error' => 'invalid_scope', 'error_description' => 'Only the mcp scope is supported' ), 400 ); } // Validate PKCE is present (required for public clients) if ( empty( $codeChallenge ) || 'S256' !== $codeChallengeMethod ) { return new WP_REST_Response( array( 'error' => 'invalid_request', 'error_description' => 'PKCE with S256 is required' ), 400 ); } // Check if user submitted the approval form. // Nonce parameter is intentionally NOT named `_wpnonce`: WordPress core // (rest_cookie_check_errors) reserves `_wpnonce` for `wp_rest` action and // returns rest_cookie_invalid_nonce 403 before our callback runs. $approved = sanitize_text_field( $request->get_param( 'approved' ) ); $formNonce = sanitize_text_field( $request->get_param( 'aiwu_oauth_nonce' ) ); $nonceAction = 'aiwu_mcp_oauth_approve_' . $clientId; if ( '1' === $approved && wp_verify_nonce( $formNonce, $nonceAction ) ) { // Generate authorization code $code = $this->generateToken( 32 ); // Store code with PKCE challenge (5 min TTL) set_transient( 'aiwu_oauth_code_' . hash( 'sha256', $code ), array( 'client_id' => $clientId, 'redirect_uri' => $redirectUri, 'code_challenge' => $codeChallenge, 'scope' => $scope, 'user_id' => absint( $userId ), 'created' => time(), 'expires' => time() + ( 5 * MINUTE_IN_SECONDS ), ), 5 * MINUTE_IN_SECONDS ); if ( $this->logging ) { WaicFrame::_()->saveDebugLogging( 'OAuth: authorization code issued for ' . $clientId, false, 'MCP' ); } // Redirect back to client with code $callbackUrl = add_query_arg( array( 'code' => $code, 'state' => $state, ), $redirectUri ); wp_redirect( esc_url_raw( $callbackUrl ), 302 ); exit; } if ( '1' === $approved ) { return new WP_REST_Response( array( 'error' => 'access_denied', 'error_description' => 'Invalid authorization nonce' ), 403 ); } // Show consent page $this->renderAuthorizePage( $request, $clientId, $scope, $clientData ); } private function restoreOAuthBrowserUser() { if ( is_user_logged_in() ) { return get_current_user_id(); } foreach ( array( 'logged_in', 'secure_auth', 'auth' ) as $scheme ) { $userId = wp_validate_auth_cookie( '', $scheme ); if ( $userId ) { wp_set_current_user( absint( $userId ) ); return absint( $userId ); } } return 0; } private function redirectToOAuthLogin() { $redirectTo = rest_url( $this->namespace . '/oauth/authorize' ); if ( ! empty( $_SERVER['REQUEST_URI'] ) ) { $requestUri = sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ); $redirectTo = home_url( $requestUri ); } wp_safe_redirect( wp_login_url( $redirectTo ) ); exit; } /** * Render the OAuth authorization/consent HTML page. */ private function renderAuthorizePage( $request, $clientId, $scope, $clientData ) { $siteName = get_bloginfo( 'name' ); $nonce = wp_create_nonce( 'aiwu_mcp_oauth_approve_' . $clientId ); $actionUrl = rest_url( $this->namespace . '/oauth/authorize' ); // Collect all original params for the form $params = array( 'client_id' => $request->get_param( 'client_id' ), 'redirect_uri' => $request->get_param( 'redirect_uri' ), 'state' => $request->get_param( 'state' ), 'scope' => $request->get_param( 'scope' ), 'code_challenge' => $request->get_param( 'code_challenge' ), 'code_challenge_method' => $request->get_param( 'code_challenge_method' ), 'response_type' => $request->get_param( 'response_type' ), 'resource' => $request->get_param( 'resource' ), ); // Resolve client name $clientName = 'MCP Client'; if ( ! empty( $clientData['client_name'] ) ) { $clientName = esc_html( $clientData['client_name'] ); } status_header( 200 ); header( 'Content-Type: text/html; charset=utf-8' ); echo ''; echo ''; echo '' . esc_html( $siteName ) . ' – MCP Authorization'; echo '
'; /* translators: %s - client name */ echo '

' . esc_html( sprintf( __( 'Authorize %s', 'ai-copilot-content-generator' ), $clientName ) ) . '

'; /* translators: %1$s - client name, %2$s - site name */ echo '

' . esc_html( sprintf( __( '%1$s wants to access your MCP tools on %2$s.', 'ai-copilot-content-generator' ), $clientName, $siteName ) ) . '

'; if ( $scope ) { echo '
' . esc_html__( 'Requested scope:', 'ai-copilot-content-generator' ) . ' ' . esc_html( $scope ) . '
'; } echo '
'; foreach ( $params as $k => $v ) { if ( $v !== null ) { echo ''; } } echo ''; echo ''; echo '
'; echo ''; echo '
'; echo '
'; echo '

' . esc_html__( 'You can revoke this access at any time from the AI client settings.', 'ai-copilot-content-generator' ) . '

'; echo '
'; exit; } /** * POST /oauth/token — Token Endpoint. * * Handles: * grant_type=authorization_code → exchange code for access + refresh token * grant_type=refresh_token → exchange refresh token for new tokens */ public function oauthToken( WP_REST_Request $request ) { if ( ! $this->oauthEnabled ) { return new WP_REST_Response( array( 'error' => 'temporarily_unavailable' ), 404 ); } $body = $request->get_body_params(); if ( empty( $body ) ) { $body = json_decode( $request->get_body(), true ); } if ( ! is_array( $body ) ) { $body = array(); } $grantType = sanitize_text_field( isset( $body['grant_type'] ) ? $body['grant_type'] : '' ); if ( 'authorization_code' === $grantType ) { return $this->oauthTokenFromCode( $body ); } if ( 'refresh_token' === $grantType ) { return $this->oauthTokenFromRefresh( $body ); } return new WP_REST_Response( array( 'error' => 'unsupported_grant_type', 'error_description' => 'Only authorization_code and refresh_token are supported.', ), 400 ); } /** * Exchange authorization code for tokens. */ private function oauthTokenFromCode( $body ) { $code = sanitize_text_field( isset( $body['code'] ) ? $body['code'] : '' ); $clientId = sanitize_text_field( isset( $body['client_id'] ) ? $body['client_id'] : '' ); $codeVerifier = sanitize_text_field( isset( $body['code_verifier'] ) ? $body['code_verifier'] : '' ); $redirectUri = $this->normalizeOAuthRedirectUri( isset( $body['redirect_uri'] ) ? $body['redirect_uri'] : '' ); if ( empty( $code ) || empty( $clientId ) || empty( $codeVerifier ) || empty( $redirectUri ) ) { return new WP_REST_Response( array( 'error' => 'invalid_request', 'error_description' => 'code, client_id, redirect_uri and code_verifier are required' ), 400 ); } if ( ! $this->validPKCEVerifier( $codeVerifier ) ) { return new WP_REST_Response( array( 'error' => 'invalid_request', 'error_description' => 'code_verifier is invalid' ), 400 ); } // Retrieve and delete the authorization code $codeKey = 'aiwu_oauth_code_' . hash( 'sha256', $code ); $codeData = get_transient( $codeKey ); delete_transient( $codeKey ); if ( ! $codeData || ! is_array( $codeData ) ) { if ( $this->logging ) { WaicFrame::_()->saveDebugLogging( 'OAuth: invalid/expired authorization code', false, 'MCP' ); } return new WP_REST_Response( array( 'error' => 'invalid_grant', 'error_description' => 'Authorization code is invalid or expired' ), 400 ); } if ( empty( $codeData['user_id'] ) || empty( $codeData['client_id'] ) ) { return new WP_REST_Response( array( 'error' => 'invalid_grant', 'error_description' => 'Authorization code is not user-bound' ), 400 ); } if ( ! is_string( $codeData['client_id'] ) || ! hash_equals( $codeData['client_id'], $clientId ) ) { return new WP_REST_Response( array( 'error' => 'invalid_grant', 'error_description' => 'client_id mismatch' ), 400 ); } $clientData = $this->getOAuthClientData( $clientId ); if ( ! $clientData || ! $this->isRegisteredOAuthRedirectUri( $redirectUri, $clientData ) ) { return new WP_REST_Response( array( 'error' => 'invalid_grant', 'error_description' => 'Client registration is invalid or expired' ), 400 ); } // Verify redirect_uri matches if ( $codeData['redirect_uri'] !== $redirectUri ) { return new WP_REST_Response( array( 'error' => 'invalid_grant', 'error_description' => 'redirect_uri mismatch' ), 400 ); } // Verify PKCE if ( ! $this->verifyPKCE( $codeVerifier, $codeData['code_challenge'] ) ) { if ( $this->logging ) { WaicFrame::_()->saveDebugLogging( 'OAuth: PKCE verification failed', false, 'MCP' ); } return new WP_REST_Response( array( 'error' => 'invalid_grant', 'error_description' => 'PKCE verification failed' ), 400 ); } if ( ! $this->userCanUseMcp( absint( $codeData['user_id'] ), $this->oauthRequiredCapability() ) ) { return new WP_REST_Response( array( 'error' => 'invalid_grant', 'error_description' => 'The authorizing user can no longer access MCP' ), 400 ); } // Issue tokens $accessToken = $this->generateToken( 48 ); $refreshToken = $this->generateToken( 48 ); $expiresIn = 3600; // 1 hour $this->storeOAuthAccessToken( $accessToken, $codeData['user_id'], $clientId, $codeData['scope'], $expiresIn ); $this->storeOAuthRefreshToken( $refreshToken, $accessToken, $codeData['user_id'], $clientId, $codeData['scope'], 7 * DAY_IN_SECONDS ); if ( $this->logging ) { WaicFrame::_()->saveDebugLogging( 'OAuth: tokens issued for ' . $codeData['client_id'], false, 'MCP' ); } $response = new WP_REST_Response( array( 'access_token' => $accessToken, 'token_type' => 'Bearer', 'expires_in' => $expiresIn, 'refresh_token' => $refreshToken, 'scope' => isset( $codeData['scope'] ) ? $codeData['scope'] : 'mcp', ), 200 ); $response->set_headers( array( 'Cache-Control' => 'no-store', 'Pragma' => 'no-cache', ) ); return $response; } /** * Exchange refresh token for new tokens. */ private function oauthTokenFromRefresh( $body ) { $refreshToken = sanitize_text_field( isset( $body['refresh_token'] ) ? $body['refresh_token'] : '' ); $clientId = sanitize_text_field( isset( $body['client_id'] ) ? $body['client_id'] : '' ); if ( empty( $refreshToken ) || empty( $clientId ) ) { return new WP_REST_Response( array( 'error' => 'invalid_request', 'error_description' => 'refresh_token and client_id are required' ), 400 ); } $data = $this->consumeRefreshToken( $refreshToken ); if ( ! $data ) { return new WP_REST_Response( array( 'error' => 'invalid_grant', 'error_description' => 'Refresh token is invalid or expired' ), 400 ); } if ( empty( $data['user_id'] ) || empty( $data['client_id'] ) || ! is_string( $data['client_id'] ) || ! hash_equals( $data['client_id'], $clientId ) ) { return new WP_REST_Response( array( 'error' => 'invalid_grant', 'error_description' => 'Refresh token is not valid for this client' ), 400 ); } if ( ! $this->getOAuthClientData( $clientId ) ) { return new WP_REST_Response( array( 'error' => 'invalid_grant', 'error_description' => 'Client registration is invalid or expired' ), 400 ); } if ( ! $this->userCanUseMcp( absint( $data['user_id'] ), $this->oauthRequiredCapability() ) ) { return new WP_REST_Response( array( 'error' => 'invalid_grant', 'error_description' => 'The authorizing user can no longer access MCP' ), 400 ); } // Revoke old access token if ( ! empty( $data['access_token'] ) ) { delete_transient( 'aiwu_oauth_at_' . hash( 'sha256', $data['access_token'] ) ); } // Issue new tokens $newAccessToken = $this->generateToken( 48 ); $newRefreshToken = $this->generateToken( 48 ); $expiresIn = 3600; $scope = isset( $data['scope'] ) ? $data['scope'] : 'mcp'; $this->storeOAuthAccessToken( $newAccessToken, $data['user_id'], $clientId, $scope, $expiresIn ); $this->storeOAuthRefreshToken( $newRefreshToken, $newAccessToken, $data['user_id'], $clientId, $scope, 7 * DAY_IN_SECONDS ); if ( $this->logging ) { WaicFrame::_()->saveDebugLogging( 'OAuth: tokens refreshed', false, 'MCP' ); } $response = new WP_REST_Response( array( 'access_token' => $newAccessToken, 'token_type' => 'Bearer', 'expires_in' => $expiresIn, 'refresh_token' => $newRefreshToken, 'scope' => $scope, ), 200 ); $response->set_headers( array( 'Cache-Control' => 'no-store', 'Pragma' => 'no-cache', ) ); return $response; } }