core = $core; $this->mcp = $mcp; $this->logging = method_exists( $mcp, 'is_logging_enabled' ) ? $mcp->is_logging_enabled() : false; $this->table_clients = $wpdb->prefix . 'mwai_mcp_oauth_clients'; $this->table_tokens = $wpdb->prefix . 'mwai_mcp_oauth_tokens'; $this->maybe_upgrade_db(); add_action( 'rest_api_init', [ $this, 'register_routes' ] ); add_filter( 'rest_post_dispatch', [ $this, 'add_www_authenticate_header' ], 10, 3 ); // WP's REST cookie nonce check silently downgrades cookie-authed users to guest // when no X-WP-Nonce is sent. The browser-driven authorize flow needs the user's // identity from the cookie without a REST nonce, so we re-validate the auth cookie // for that route. CSRF is enforced separately via our own consent nonce on POST. add_filter( 'rest_authentication_errors', [ $this, 'reauth_for_authorize' ], 200 ); // Serve well-known metadata at the host root too. RFC 9728/8414 specify the // well-known URI is built by inserting /.well-known/ between the host // and the path of the resource/issuer, so strict clients query the host root // rather than the nested REST path. Run very early to short-circuit WP's 404. add_action( 'parse_request', [ $this, 'handle_host_root_wellknown' ], 1 ); } /** * Serve OAuth well-known metadata from the host root. Handles all three URL * shapes that clients use in the wild: bare host-root, host-root + resource * path (RFC strict), and the nested REST path is already covered by the REST * route registration. */ public function handle_host_root_wellknown() { $uri = isset( $_SERVER['REQUEST_URI'] ) ? (string) $_SERVER['REQUEST_URI'] : ''; $path = strtok( $uri, '?' ); if ( $path === false || strpos( $path, '/.well-known/' ) !== 0 ) { return; } if ( strpos( $path, '/.well-known/oauth-protected-resource' ) === 0 ) { if ( $this->logging ) { error_log( '[AI Engine MCP OAuth] Host-root PRM hit: ' . $path ); } $this->emit_json( $this->protected_resource_metadata() ); } if ( strpos( $path, '/.well-known/oauth-authorization-server' ) === 0 ) { if ( $this->logging ) { error_log( '[AI Engine MCP OAuth] Host-root ASM hit: ' . $path ); } $this->emit_json( $this->authorization_server_metadata() ); } // Only the form carrying our own issuer path: a bare /.well-known/openid-configuration may // belong to an SSO or OpenID provider plugin on the site, and answering it would break it. if ( strpos( $path, '/.well-known/openid-configuration/' ) === 0 && strpos( $path, '/' . $this->namespace ) !== false ) { if ( $this->logging ) { error_log( '[AI Engine MCP OAuth] Host-root OIDC hit: ' . $path ); } $this->emit_json( $this->openid_configuration() ); } } /** * Purge the OAuth discovery URLs from whatever page cache sits in front of WordPress. * * A cache that stored a 404 for these paths keeps serving it long after the plugin * can answer properly, and the way sites get into that state is the plugin being * inactive for a moment: an update, or a manual deactivation. Our no-cache headers * cannot help, because our code is not running when that 404 is produced, and the * result is an MCP connection that fails intermittently for days with nothing wrong * on the site. Purging the two URLs the moment we come back is the only cure. * * Static, and called from a real activation hook, because during activation * WordPress includes the plugin long after plugins_loaded has fired, so none of the * usual module instances exist yet. * * LiteSpeed is handled directly, since that is where this was diagnosed. Any other * cache can listen to mwai_mcp_purge_discovery_urls, which carries the same list. */ public static function purge_discovery_cache() { $urls = [ home_url( '/.well-known/oauth-protected-resource' ), home_url( '/.well-known/oauth-authorization-server' ), ]; foreach ( $urls as $url ) { do_action( 'litespeed_purge_url', $url ); } do_action( 'mwai_mcp_purge_discovery_urls', $urls ); } private function emit_json( $payload ) { status_header( 200 ); nocache_headers(); header( 'Content-Type: application/json; charset=utf-8' ); header( 'Access-Control-Allow-Origin: *' ); echo wp_json_encode( $payload ); exit; } private function protected_resource_metadata() { $issuer = $this->issuer(); return [ 'resource' => rest_url( $this->namespace . '/http' ), 'authorization_servers' => [ $issuer ], 'bearer_methods_supported' => [ 'header' ], 'scopes_supported' => [ 'mcp' ], 'resource_documentation' => 'https://meowapps.com/ai-engine/', ]; } // One definition for the issuer: the metadata and the iss sent back after consent must match // exactly (RFC 9207), or strict clients refuse the connection. private function issuer() { return rest_url( $this->namespace ); } private function authorization_server_metadata() { $issuer = $this->issuer(); return [ 'issuer' => $issuer, 'authorization_endpoint' => rest_url( $this->namespace . '/oauth/authorize' ), 'token_endpoint' => rest_url( $this->namespace . '/oauth/token' ), 'registration_endpoint' => rest_url( $this->namespace . '/oauth/register' ), 'revocation_endpoint' => rest_url( $this->namespace . '/oauth/revoke' ), 'response_types_supported' => [ 'code' ], 'grant_types_supported' => [ 'authorization_code', 'refresh_token' ], 'token_endpoint_auth_methods_supported' => [ 'none', 'client_secret_basic', 'client_secret_post' ], 'code_challenge_methods_supported' => [ 'S256' ], 'authorization_response_iss_parameter_supported' => true, 'scopes_supported' => [ 'mcp' ], ]; } public function reauth_for_authorize( $result ) { // Match the RESOLVED REST route, exactly. This used to be a substring test against // $_SERVER['REQUEST_URI'], which includes the query string: appending // "?x=/mcp/v1/oauth/authorize" to ANY REST request made this fire, restoring the // cookie user's full identity on a route that WP had deliberately downgraded to // guest for lack of an X-WP-Nonce. That turned every authenticated REST endpoint // into a CSRF sink, e.g. a top-level navigation to /wp/v2/users with _method=POST // creating an administrator (CVE-2026-15988). WP dispatches the request using this // same query var, so an exact comparison against it cannot disagree with the route // that actually runs. $route = isset( $GLOBALS['wp']->query_vars['rest_route'] ) ? (string) $GLOBALS['wp']->query_vars['rest_route'] : ''; if ( $route === '' ) { return $result; } $route = '/' . trim( $route, '/' ); if ( $route !== '/' . $this->namespace . '/oauth/authorize' ) { return $result; } if ( !is_user_logged_in() ) { $user_id = wp_validate_auth_cookie( '', 'logged_in' ); if ( $user_id ) { wp_set_current_user( (int) $user_id ); } } return $result; } #region DB schema private function maybe_upgrade_db() { if ( get_option( 'mwai_mcp_oauth_db_version' ) === self::DB_VERSION ) { return; } global $wpdb; $charset_collate = $wpdb->get_charset_collate(); $sql_clients = "CREATE TABLE {$this->table_clients} ( id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, client_id VARCHAR(64) NOT NULL, client_secret_hash VARCHAR(64) NULL, client_name VARCHAR(255) NULL, redirect_uris LONGTEXT NOT NULL, grant_types VARCHAR(255) NOT NULL DEFAULT 'authorization_code,refresh_token', token_endpoint_auth_method VARCHAR(32) NOT NULL DEFAULT 'none', scope VARCHAR(255) NULL, created DATETIME NOT NULL, PRIMARY KEY (id), UNIQUE KEY client_id (client_id) ) {$charset_collate};"; $sql_tokens = "CREATE TABLE {$this->table_tokens} ( id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT, client_id VARCHAR(64) NOT NULL, user_id BIGINT(20) UNSIGNED NOT NULL, access_token_hash VARCHAR(64) NOT NULL, refresh_token_hash VARCHAR(64) NULL, access_expires DATETIME NOT NULL, refresh_expires DATETIME NULL, scope VARCHAR(255) NULL, created DATETIME NOT NULL, last_used DATETIME NULL, revoked TINYINT(1) NOT NULL DEFAULT 0, PRIMARY KEY (id), KEY access_token_hash (access_token_hash), KEY refresh_token_hash (refresh_token_hash), KEY client_id (client_id), KEY user_id (user_id) ) {$charset_collate};"; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; dbDelta( $sql_clients ); dbDelta( $sql_tokens ); update_option( 'mwai_mcp_oauth_db_version', self::DB_VERSION ); } #endregion #region Route registration public function register_routes() { // RFC 9728 — Protected Resource Metadata register_rest_route( $this->namespace, '/.well-known/oauth-protected-resource', [ 'methods' => 'GET', 'callback' => [ $this, 'handle_resource_metadata' ], 'permission_callback' => '__return_true', ] ); // RFC 8414 — Authorization Server Metadata register_rest_route( $this->namespace, '/.well-known/oauth-authorization-server', [ 'methods' => 'GET', 'callback' => [ $this, 'handle_as_metadata' ], 'permission_callback' => '__return_true', ] ); // OpenID Connect Discovery, appended to the issuer path. Claude tries the host-root // /.well-known/ forms first, then this one. Managed hosts like SiteGround serve their own // 404 for anything under the host-root /.well-known/ and never reach WordPress, so without // this route Claude found no metadata and failed with "Couldn't register with ... sign-in // service" (HS 3506632298, 2026-11-06). The nested path does reach WordPress there. register_rest_route( $this->namespace, '/.well-known/openid-configuration', [ 'methods' => 'GET', 'callback' => [ $this, 'handle_openid_configuration' ], 'permission_callback' => '__return_true', ] ); register_rest_route( $this->namespace, '/oauth/jwks', [ 'methods' => 'GET', 'callback' => [ $this, 'handle_jwks' ], 'permission_callback' => '__return_true', ] ); // RFC 7591 — Dynamic Client Registration register_rest_route( $this->namespace, '/oauth/register', [ 'methods' => 'POST', 'callback' => [ $this, 'handle_register' ], 'permission_callback' => '__return_true', ] ); // Authorization endpoint (browser-driven, returns HTML or 302) register_rest_route( $this->namespace, '/oauth/authorize', [ 'methods' => [ 'GET', 'POST' ], 'callback' => [ $this, 'handle_authorize' ], 'permission_callback' => '__return_true', ] ); // Token endpoint register_rest_route( $this->namespace, '/oauth/token', [ 'methods' => 'POST', 'callback' => [ $this, 'handle_token' ], 'permission_callback' => '__return_true', ] ); // RFC 7009 — Token Revocation register_rest_route( $this->namespace, '/oauth/revoke', [ 'methods' => 'POST', 'callback' => [ $this, 'handle_revoke' ], 'permission_callback' => '__return_true', ] ); // Admin-only: list active grants register_rest_route( $this->namespace, '/oauth/apps', [ 'methods' => 'GET', 'callback' => [ $this, 'handle_apps_list' ], 'permission_callback' => function () { return current_user_can( 'manage_options' ); }, ] ); // Admin-only: revoke a grant by id register_rest_route( $this->namespace, '/oauth/apps/(?P\d+)', [ 'methods' => 'DELETE', 'callback' => [ $this, 'handle_apps_revoke' ], 'permission_callback' => function () { return current_user_can( 'manage_options' ); }, ] ); // Admin-only: switch a grant between Full access and Read-only register_rest_route( $this->namespace, '/oauth/apps/(?P\d+)/access', [ 'methods' => 'POST', 'callback' => [ $this, 'handle_apps_access' ], 'permission_callback' => function () { return current_user_can( 'manage_options' ); }, ] ); } #endregion #region Discovery (well-known) public function handle_resource_metadata() { return new WP_REST_Response( $this->protected_resource_metadata(), 200 ); } public function handle_as_metadata() { return new WP_REST_Response( $this->authorization_server_metadata(), 200 ); } public function handle_openid_configuration() { return new WP_REST_Response( $this->openid_configuration(), 200 ); } // No ID tokens are issued (the mcp scope is plain OAuth), so there are no keys to publish. public function handle_jwks() { return new WP_REST_Response( [ 'keys' => [] ], 200 ); } // The OAuth metadata plus the fields OIDC discovery documents must carry, so clients that // validate this URL strictly against the OpenID schema still accept it. private function openid_configuration() { return array_merge( $this->authorization_server_metadata(), [ 'jwks_uri' => rest_url( $this->namespace . '/oauth/jwks' ), 'subject_types_supported' => [ 'public' ], 'id_token_signing_alg_values_supported' => [ 'RS256' ], ] ); } #endregion #region Dynamic Client Registration (RFC 7591) public function handle_register( WP_REST_Request $request ) { $body = json_decode( $request->get_body(), true ); if ( !is_array( $body ) ) { return $this->oauth_error( 'invalid_client_metadata', 'Request body must be JSON.', 400 ); } $redirect_uris = $body['redirect_uris'] ?? null; if ( !is_array( $redirect_uris ) || empty( $redirect_uris ) ) { return $this->oauth_error( 'invalid_redirect_uri', 'redirect_uris is required and must be a non-empty array.', 400 ); } foreach ( $redirect_uris as $uri ) { if ( !is_string( $uri ) || $uri === '' ) { return $this->oauth_error( 'invalid_redirect_uri', 'Each redirect_uri must be a non-empty string.', 400 ); } // Light validation — allow http(s) and custom schemes (desktop clients use them). if ( !preg_match( '#^[a-z][a-z0-9+.\-]*://#i', $uri ) ) { return $this->oauth_error( 'invalid_redirect_uri', "redirect_uri must include a scheme: {$uri}", 400 ); } } $auth_method = isset( $body['token_endpoint_auth_method'] ) ? (string) $body['token_endpoint_auth_method'] : 'none'; if ( !in_array( $auth_method, [ 'none', 'client_secret_basic', 'client_secret_post' ], true ) ) { return $this->oauth_error( 'invalid_client_metadata', "Unsupported token_endpoint_auth_method: {$auth_method}", 400 ); } $grant_types = $body['grant_types'] ?? [ 'authorization_code', 'refresh_token' ]; if ( !is_array( $grant_types ) ) { $grant_types = [ 'authorization_code', 'refresh_token' ]; } foreach ( $grant_types as $gt ) { if ( !in_array( $gt, [ 'authorization_code', 'refresh_token' ], true ) ) { return $this->oauth_error( 'invalid_client_metadata', "Unsupported grant_type: {$gt}", 400 ); } } $client_id = $this->random_token( 32 ); $client_secret = null; $client_secret_hash = null; if ( $auth_method !== 'none' ) { $client_secret = $this->random_token( 48 ); $client_secret_hash = hash( 'sha256', $client_secret ); } $client_name = isset( $body['client_name'] ) ? sanitize_text_field( (string) $body['client_name'] ) : 'Unnamed MCP Client'; global $wpdb; $inserted = $wpdb->insert( $this->table_clients, [ 'client_id' => $client_id, 'client_secret_hash' => $client_secret_hash, 'client_name' => $client_name, 'redirect_uris' => wp_json_encode( array_values( $redirect_uris ) ), 'grant_types' => implode( ',', $grant_types ), 'token_endpoint_auth_method' => $auth_method, 'scope' => 'mcp', 'created' => current_time( 'mysql', 1 ), ] ); if ( !$inserted ) { return $this->oauth_error( 'server_error', 'Could not persist client registration.', 500 ); } if ( $this->logging ) { error_log( '[AI Engine MCP OAuth] Registered client: ' . $client_name . ' (' . $client_id . ')' ); } $this->prune_orphan_clients(); $response = [ 'client_id' => $client_id, 'client_name' => $client_name, 'redirect_uris' => array_values( $redirect_uris ), 'grant_types' => $grant_types, 'token_endpoint_auth_method' => $auth_method, 'client_id_issued_at' => time(), ]; if ( $client_secret !== null ) { $response['client_secret'] = $client_secret; $response['client_secret_expires_at'] = 0; // never } return new WP_REST_Response( $response, 201 ); } #endregion #region Authorize (browser flow) public function handle_authorize( WP_REST_Request $request ) { $method = $request->get_method(); if ( $method === 'POST' ) { $this->handle_authorize_submit( $request ); exit; } // GET — render consent page or redirect to login $params = [ 'response_type' => (string) ( $request->get_param( 'response_type' ) ?? '' ), 'client_id' => (string) ( $request->get_param( 'client_id' ) ?? '' ), 'redirect_uri' => (string) ( $request->get_param( 'redirect_uri' ) ?? '' ), 'state' => (string) ( $request->get_param( 'state' ) ?? '' ), 'scope' => (string) ( $request->get_param( 'scope' ) ?? 'mcp' ), 'code_challenge' => (string) ( $request->get_param( 'code_challenge' ) ?? '' ), 'code_challenge_method' => (string) ( $request->get_param( 'code_challenge_method' ) ?? '' ), // RFC 8707. ChatGPT and other current clients send this; we carry it through the // consent POST so it survives to the token exchange. Deliberately not enforced: // we expose exactly one resource, so a mismatch cannot widen a token's reach, and // rejecting on it would break any client whose idea of the URL differs harmlessly // (a trailing slash, www against apex). It is recorded, and logged when it differs. 'resource' => (string) ( $request->get_param( 'resource' ) ?? '' ), ]; // Log the hit itself. Without this, a client that registers and then stops is // indistinguishable from a client that reached the consent screen and was refused, // because every branch below only renders a page in the user's browser. That // ambiguity has cost several support rounds. if ( $this->logging ) { error_log( '[AI Engine MCP OAuth] → GET /oauth/authorize client_id=' . ( $params['client_id'] ?: '(none)' ) . ' redirect_uri=' . ( $params['redirect_uri'] ?: '(none)' ) . ' scope=' . $params['scope'] ); } if ( $params['response_type'] !== 'code' ) { $this->log_authorize_refusal( 'unsupported response_type: ' . ( $params['response_type'] ?: '(none)' ) ); $this->render_error_page( 'Unsupported response_type. Only "code" is supported.' ); exit; } if ( $params['code_challenge'] === '' || $params['code_challenge_method'] !== 'S256' ) { $this->log_authorize_refusal( 'PKCE missing or not S256 (method: ' . ( $params['code_challenge_method'] ?: '(none)' ) . ')' ); $this->render_error_page( 'PKCE is required: provide code_challenge and code_challenge_method=S256.' ); exit; } $client = $this->get_client( $params['client_id'] ); if ( !$client ) { $this->log_authorize_refusal( 'unknown client_id ' . ( $params['client_id'] ?: '(none)' ) ); $this->render_error_page( 'Unknown client_id. The client must register via Dynamic Client Registration first.' ); exit; } if ( !$this->redirect_uri_registered( $client, $params['redirect_uri'] ) ) { $this->log_authorize_refusal( 'redirect_uri not registered for this client: ' . $params['redirect_uri'] ); $this->render_error_page( 'redirect_uri does not match any registered URI for this client.' ); exit; } // Authentication gate — bounce to wp-login.php if not logged in. if ( !is_user_logged_in() ) { $current_url = rest_url( $this->namespace . '/oauth/authorize' ); $current_url = add_query_arg( $params, $current_url ); // Never let this redirect be cached. Full-page caches that also cache the REST // API (LiteSpeed Cache's "Cache REST API", for one) key it on the request URL, // so the first hit on a given authorize URL wins. That first hit is typically a // backend probe from the connector infrastructure, with no cookies, and the // user's real browser is then served the cached bounce-to-login instead of the // consent screen. Every attempt fails, and nothing reaches PHP to be logged. nocache_headers(); wp_safe_redirect( wp_login_url( $current_url ) ); exit; } $user = wp_get_current_user(); // Capability gate. MCP grants administrative tool access by design; allowing a // non-admin to mint an OAuth token would let them act through the MCP layer with // privileges they do not hold in WordPress itself. if ( !$this->user_can_authorize( $user->ID ) ) { if ( $this->logging ) { error_log( '[AI Engine MCP OAuth] ❌ Non-admin user ' . $user->ID . ' tried to authorize client ' . $params['client_id'] ); } $this->render_error_page( $this->refusal_message() ); exit; } $this->render_consent_page( $client, $params, $user ); exit; } private function log_authorize_refusal( $reason ) { if ( $this->logging ) { error_log( '[AI Engine MCP OAuth] ❌ Authorize refused: ' . $reason ); } } private function handle_authorize_submit( WP_REST_Request $request ) { if ( !is_user_logged_in() ) { nocache_headers(); wp_safe_redirect( wp_login_url() ); exit; } if ( !$this->user_can_authorize( get_current_user_id() ) ) { if ( $this->logging ) { error_log( '[AI Engine MCP OAuth] ❌ Non-admin user ' . get_current_user_id() . ' attempted authorize submit' ); } $this->render_error_page( $this->refusal_message() ); exit; } $nonce = (string) $request->get_param( '_mwai_nonce' ); if ( !wp_verify_nonce( $nonce, self::NONCE_ACTION ) ) { $this->render_error_page( 'Security check failed. Please try again from your application.' ); exit; } $client_id = (string) $request->get_param( 'client_id' ); $redirect_uri = (string) $request->get_param( 'redirect_uri' ); $state = (string) ( $request->get_param( 'state' ) ?? '' ); $code_challenge = (string) $request->get_param( 'code_challenge' ); $code_challenge_method = (string) $request->get_param( 'code_challenge_method' ); // Built from the consent screen's choice, never from what the client asked for. $scope = $request->get_param( 'access' ) === 'read' ? self::SCOPE_READ : self::SCOPE_FULL; $action = (string) ( $request->get_param( 'action' ) ?? 'deny' ); $client = $this->get_client( $client_id ); if ( !$client || !$this->redirect_uri_registered( $client, $redirect_uri ) ) { $this->render_error_page( 'Invalid client or redirect_uri.' ); exit; } if ( $action !== 'approve' ) { $params = [ 'error' => 'access_denied', 'error_description' => 'User denied the request.' ]; if ( $state !== '' ) { $params['state'] = $state; } $params['iss'] = $this->issuer(); wp_redirect( $this->append_params( $redirect_uri, $params ) ); exit; } // Generate authorization code and stash everything needed to mint a token later. $code = $this->random_token( 48 ); $code_data = [ 'client_id' => $client_id, 'user_id' => get_current_user_id(), 'redirect_uri' => $redirect_uri, 'code_challenge' => $code_challenge, 'code_challenge_method' => $code_challenge_method, 'scope' => $scope, 'resource' => (string) ( $request->get_param( 'resource' ) ?? '' ), ]; if ( $this->logging && $code_data['resource'] !== '' && $code_data['resource'] !== rest_url( $this->namespace . '/http' ) ) { error_log( '[AI Engine MCP OAuth] Client asked for resource ' . $code_data['resource'] . ', we serve ' . rest_url( $this->namespace . '/http' ) . '. Accepted anyway.' ); } // Stored as an option, not a transient: with a persistent object cache, WordPress keeps // transients only in that cache, and a cache not shared by every PHP server (or one that // evicts) lost the code between consent and token: "Authorization code is invalid or // expired." seconds after approval (Torii, storeoh.com and tryonartsandcrafts.org, 2026-10). $this->purge_expired_auth_codes(); add_option( $this->auth_code_key( $code ), [ 'data' => $code_data, 'expires' => time() + self::AUTH_CODE_TTL ], '', false ); $params = [ 'code' => $code ]; if ( $state !== '' ) { $params['state'] = $state; } // RFC 9207: say which server answered, so a client connected to several sites (Torii) can // check the code came back from the one it sent the user to, and refuse a mix-up. $params['iss'] = $this->issuer(); if ( $this->logging ) { error_log( '[AI Engine MCP OAuth] Authorized user ' . get_current_user_id() . ' for client ' . $client_id ); } wp_redirect( $this->append_params( $redirect_uri, $params ) ); exit; } private function auth_code_key( $code ) { return 'mwai_mcp_oauth_code_' . hash( 'sha256', $code ); } // Codes nobody redeemed (an abandoned consent) would otherwise stay in wp_options forever. private function purge_expired_auth_codes() { global $wpdb; $names = $wpdb->get_col( $wpdb->prepare( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s LIMIT 50", $wpdb->esc_like( 'mwai_mcp_oauth_code_' ) . '%' ) ); foreach ( $names as $name ) { $stored = get_option( $name ); if ( !is_array( $stored ) || ( $stored['expires'] ?? 0 ) < time() ) { delete_option( $name ); } } } #endregion #region Token endpoint public function handle_token( WP_REST_Request $request ) { $grant_type = (string) ( $request->get_param( 'grant_type' ) ?? '' ); // A failing refresh used to be completely silent, which made "the connector stops // working after a while and re-authorizes itself" impossible to diagnose: every // branch below returns a bare OAuth error to a client that reports it as a generic // permission problem. Tokens are never logged, only a short hash prefix so two lines // can be tied to the same grant. if ( $this->logging ) { error_log( '[AI Engine MCP OAuth] → /oauth/token grant_type=' . ( $grant_type ?: '(none)' ) ); } if ( $grant_type === 'authorization_code' ) { return $this->handle_token_auth_code( $request ); } if ( $grant_type === 'refresh_token' ) { return $this->handle_token_refresh( $request ); } if ( $this->logging ) { error_log( '[AI Engine MCP OAuth] ❌ Unsupported grant_type: ' . ( $grant_type ?: '(none)' ) ); } return $this->oauth_error( 'unsupported_grant_type', 'Supported: authorization_code, refresh_token.', 400 ); } /** * Short, non-reversible marker for a token, so log lines can be correlated without * ever writing a usable credential to disk. */ private function token_marker( $token ) { return substr( hash( 'sha256', (string) $token ), 0, 8 ); } private function handle_token_auth_code( WP_REST_Request $request ) { $code = (string) ( $request->get_param( 'code' ) ?? '' ); $redirect_uri = (string) ( $request->get_param( 'redirect_uri' ) ?? '' ); $code_verifier = (string) ( $request->get_param( 'code_verifier' ) ?? '' ); $client_id = (string) ( $request->get_param( 'client_id' ) ?? '' ); if ( $code === '' || $redirect_uri === '' || $code_verifier === '' ) { return $this->oauth_error( 'invalid_request', 'Missing code, redirect_uri, or code_verifier.', 400 ); } $key = $this->auth_code_key( $code ); $stored = get_option( $key ); // Single-use: whoever deletes the option owns the code, so two concurrent requests cannot // both redeem it. if ( !is_array( $stored ) || !delete_option( $key ) || ( $stored['expires'] ?? 0 ) < time() ) { return $this->oauth_error( 'invalid_grant', 'Authorization code is invalid or expired.', 400 ); } $code_data = $stored['data']; if ( $code_data['redirect_uri'] !== $redirect_uri ) { return $this->oauth_error( 'invalid_grant', 'redirect_uri mismatch.', 400 ); } $client = $this->get_client( $code_data['client_id'] ); if ( !$client ) { return $this->oauth_error( 'invalid_client', 'Client not found.', 401 ); } if ( $client_id !== '' && $client_id !== $client->client_id ) { return $this->oauth_error( 'invalid_client', 'client_id mismatch.', 401 ); } if ( !$this->authenticate_client_if_required( $client, $request ) ) { return $this->oauth_error( 'invalid_client', 'Client authentication failed.', 401 ); } // Verify PKCE. $expected_challenge = rtrim( strtr( base64_encode( hash( 'sha256', $code_verifier, true ) ), '+/', '-_' ), '=' ); if ( !hash_equals( (string) $code_data['code_challenge'], $expected_challenge ) ) { return $this->oauth_error( 'invalid_grant', 'PKCE verification failed.', 400 ); } // A reconnect with the same client starts a new chain next to the old one, which stays able // to refresh: give the whole grant the level just approved, so an old full-access chain can't // outlive it. Clients that register again on each reconnect get a new client_id, so their // older connection keeps its own level and shows separately in Connected Apps. $this->set_grant_scope( $client->client_id, (int) $code_data['user_id'], (string) $code_data['scope'] ); return $this->issue_token_pair( $client->client_id, (int) $code_data['user_id'], (string) $code_data['scope'] ); } private function handle_token_refresh( WP_REST_Request $request ) { $refresh_token = (string) ( $request->get_param( 'refresh_token' ) ?? '' ); $client_id = (string) ( $request->get_param( 'client_id' ) ?? '' ); if ( $refresh_token === '' ) { if ( $this->logging ) { error_log( '[AI Engine MCP OAuth] ❌ Refresh rejected: no refresh_token in the request.' ); } return $this->oauth_error( 'invalid_request', 'Missing refresh_token.', 400 ); } global $wpdb; $hash = hash( 'sha256', $refresh_token ); $marker = $this->token_marker( $refresh_token ); $row = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$this->table_tokens} WHERE refresh_token_hash = %s AND revoked = 0 LIMIT 1", $hash ) ); if ( !$row ) { $row = $this->recover_rotated_token( $hash, $marker ); } if ( !$row ) { if ( $this->logging ) { // Distinguish "never existed" from "already rotated or revoked": the second is the // signature of a client refreshing twice with the same token, which rotation kills. $revoked = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$this->table_tokens} WHERE refresh_token_hash = %s LIMIT 1", $hash ) ); error_log( '[AI Engine MCP OAuth] ❌ Refresh rejected for token ' . $marker . ': ' . ( $revoked ? 'the grant exists but is revoked (already rotated, or revoked in Connected Apps).' : 'no grant matches this refresh token.' ) ); } return $this->oauth_error( 'invalid_grant', 'Refresh token is invalid or revoked.', 400 ); } if ( $row->refresh_expires && strtotime( $row->refresh_expires . ' UTC' ) < time() ) { if ( $this->logging ) { error_log( '[AI Engine MCP OAuth] ❌ Refresh rejected for token ' . $marker . ': refresh token expired on ' . $row->refresh_expires . ' UTC.' ); } return $this->oauth_error( 'invalid_grant', 'Refresh token expired.', 400 ); } $client = $this->get_client( $row->client_id ); if ( !$client ) { if ( $this->logging ) { error_log( '[AI Engine MCP OAuth] ❌ Refresh rejected for token ' . $marker . ': client ' . $row->client_id . ' no longer exists.' ); } return $this->oauth_error( 'invalid_client', 'Client not found.', 401 ); } if ( $client_id !== '' && $client_id !== $client->client_id ) { if ( $this->logging ) { error_log( '[AI Engine MCP OAuth] ❌ Refresh rejected for token ' . $marker . ': client_id in the request does not match the one on the grant.' ); } return $this->oauth_error( 'invalid_client', 'client_id mismatch.', 401 ); } if ( !$this->authenticate_client_if_required( $client, $request ) ) { if ( $this->logging ) { error_log( '[AI Engine MCP OAuth] ❌ Refresh rejected for token ' . $marker . ': client authentication failed (method ' . $client->token_endpoint_auth_method . '). If this client authenticates with client_secret_basic, check that the host' . ' forwards the Authorization header on POST requests.' ); } return $this->oauth_error( 'invalid_client', 'Client authentication failed.', 401 ); } // Refresh-token rotation (OAuth 2.1 best practice): the old refresh token is // spent, but the access token issued with it is NOT. See TOKEN_ROTATED. $wpdb->update( $this->table_tokens, [ 'revoked' => self::TOKEN_ROTATED ], [ 'id' => $row->id ] ); if ( $this->logging ) { error_log( '[AI Engine MCP OAuth] ✅ Refresh accepted for token ' . $marker . ', user ' . (int) $row->user_id . ', client ' . $client->client_id . '. New pair issued; the previous access token stays valid until ' . $row->access_expires . ' UTC.' ); } return $this->issue_token_pair( $row->client_id, (int) $row->user_id, (string) $row->scope, $row ); } /** * A rotated refresh token that comes back is refreshed again, without touching any * other token of the grant. * * Two real cases send one: a client that refreshed but never kept the new pair * (ChatGPT, 2026-09-25: refreshed on Sep 4, kept the Sep 3 token), and a client * running parallel sessions that each hold their own copy of the grant (ChatGPT * Desktop Work sessions, reported by jordiss on 3.8.2, 2026-09-26). 3.8.2 recovered * the first case by revoking the newer row, which is exactly what the parallel * sessions hold: each session's refresh knocked out the other's, and both ended in * invalid_grant. So a returning token now branches the grant: it gets its own new * pair, and every sibling keeps working. * * The trade-off is that a rotated refresh token stays usable until it expires or the * grant is revoked, like a non-rotating refresh token. Revoking in Connected Apps (or * through /oauth/revoke) marks every row of the grant revoked, rotated ones included, * so revocation still cuts all sessions at once. */ private function recover_rotated_token( $hash, $marker ) { global $wpdb; $old = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$this->table_tokens} WHERE refresh_token_hash = %s AND revoked = %d LIMIT 1", $hash, self::TOKEN_ROTATED ) ); if ( !$old || ( $old->refresh_expires && strtotime( $old->refresh_expires . ' UTC' ) < time() ) ) { return null; } if ( $this->logging ) { error_log( '[AI Engine MCP OAuth] ♻️ Refresh token ' . $marker . ' was already rotated (a parallel' . ' session, or a client that kept an older copy): issuing it a new pair, other sessions keep theirs.' ); } return $old; } /** * Every refresh inserts a new row, so a grant is a chain of rows. $previous is the * rotated row: the new one inherits its authorization date and last use, otherwise * Connected Apps shows an app used every hour as "authorized today, never used". */ private function issue_token_pair( $client_id, $user_id, $scope, $previous = null ) { global $wpdb; $access_token = $this->random_token( 48 ); $refresh_token = $this->random_token( 48 ); $now = time(); $wpdb->insert( $this->table_tokens, [ 'client_id' => $client_id, 'user_id' => $user_id, 'access_token_hash' => hash( 'sha256', $access_token ), 'refresh_token_hash' => hash( 'sha256', $refresh_token ), 'access_expires' => gmdate( 'Y-m-d H:i:s', $now + self::ACCESS_TOKEN_TTL ), 'refresh_expires' => gmdate( 'Y-m-d H:i:s', $now + self::REFRESH_TOKEN_TTL ), 'scope' => $scope, 'created' => $previous ? $previous->created : gmdate( 'Y-m-d H:i:s', $now ), 'last_used' => $previous ? $previous->last_used : null, ] ); $this->prune_dead_tokens(); $response = new WP_REST_Response( [ 'access_token' => $access_token, 'token_type' => 'Bearer', 'expires_in' => self::ACCESS_TOKEN_TTL, 'refresh_token' => $refresh_token, 'scope' => $scope, ], 200 ); $response->header( 'Cache-Control', 'no-store' ); $response->header( 'Pragma', 'no-cache' ); return $response; } // The Authorization header as the client sent it. Apache often keeps it out of $_SERVER unless // the .htaccess passes it on (the classic SetEnvIf line), so WordPress sees nothing and every // OAuth call fails right after a sign-in that looked fine ("Action discovery failed" in // ChatGPT). apache_request_headers() usually still has it. public static function authorization_header( WP_REST_Request $request ): string { $header = (string) $request->get_header( 'authorization' ); if ( $header !== '' ) { return $header; } if ( function_exists( 'apache_request_headers' ) ) { foreach ( (array) apache_request_headers() as $name => $value ) { if ( strcasecmp( (string) $name, 'Authorization' ) === 0 ) { return (string) $value; } } } return (string) ( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? '' ); } private function authenticate_client_if_required( $client, WP_REST_Request $request ) { if ( $client->token_endpoint_auth_method === 'none' ) { return true; } $provided_secret = ''; if ( $client->token_endpoint_auth_method === 'client_secret_basic' ) { $auth = self::authorization_header( $request ); if ( $auth && preg_match( '#^Basic\s+(.+)$#i', $auth, $m ) ) { $decoded = base64_decode( $m[1], true ); if ( $decoded && strpos( $decoded, ':' ) !== false ) { [ $cid, $secret ] = explode( ':', $decoded, 2 ); if ( $cid === $client->client_id ) { $provided_secret = $secret; } } } elseif ( isset( $_SERVER['PHP_AUTH_USER'] ) && $_SERVER['PHP_AUTH_USER'] === $client->client_id ) { // Apache with mod_php performs HTTP Basic auth itself: it moves the credentials // into PHP_AUTH_USER/PHP_AUTH_PW and never exposes the header, so get_header() // above finds nothing even though the client sent one. A Bearer header is left // alone, which is exactly why MCP tool calls keep working on these hosts while // every single token refresh is rejected as invalid_client: the connection dies // once the access token ages out and never comes back. $provided_secret = (string) ( $_SERVER['PHP_AUTH_PW'] ?? '' ); } } else { $provided_secret = (string) ( $request->get_param( 'client_secret' ) ?? '' ); } if ( $provided_secret === '' || !$client->client_secret_hash ) { return false; } return hash_equals( $client->client_secret_hash, hash( 'sha256', $provided_secret ) ); } #endregion #region Revocation public function handle_revoke( WP_REST_Request $request ) { $token = (string) ( $request->get_param( 'token' ) ?? '' ); if ( $token === '' ) { // RFC 7009: return 200 even on unknown tokens to avoid information leakage. return new WP_REST_Response( null, 200 ); } global $wpdb; $hash = hash( 'sha256', $token ); $row = $wpdb->get_row( $wpdb->prepare( "SELECT id, client_id, user_id, revoked FROM {$this->table_tokens} WHERE access_token_hash = %s OR refresh_token_hash = %s LIMIT 1", $hash, $hash ) ); // A client tidying up after a refresh revokes the token it just replaced. That token is // already rotated, so retire only its row: revoking the whole grant also killed the pair // the client had just received, and the connection died a few days later (Frank Werner, // 2026-10). Only a live token ends every session of the grant. An already revoked row is // left alone: the old access and refresh tokens share one row, so a client revoking both // (or retrying) hit it a second time and killed the new pair after all. if ( $row && (int) $row->revoked === self::TOKEN_ROTATED ) { $wpdb->update( $this->table_tokens, [ 'revoked' => self::TOKEN_REVOKED ], [ 'id' => $row->id ] ); } else if ( $row && (int) $row->revoked === 0 ) { $this->revoke_grant( $row->client_id, (int) $row->user_id ); } return new WP_REST_Response( null, 200 ); } #endregion #region Capability gate /** * Whether a user is allowed to authorize an OAuth client and to use an OAuth * access token against the MCP endpoint. Defaults to administrator only, * matching the documented MCP access model. The filter exists so the planned * multi-user MCP work can broaden this safely once per-token capability * scoping lands; until then, allowing a non-admin here re-opens CVE-class * privilege escalation through tools like wp_create_user. * * Test manage_options, not the 'administrator' role name. Passing a role name * to user_can() only matches when that exact key sits in the user's * capabilities meta, so admin-equivalent accounts (custom roles, caps granted * individually, or a plugin filtering user_has_cap) were refused at the * consent screen while every wp-admin settings page loaded fine for them. * manage_options keeps the privilege-escalation fix intact: editors and below * do not hold it, and multisite super admins pass via WP_User::has_cap(). */ public function user_can_authorize( $user_id ) { $user_id = (int) $user_id; $allowed = $user_id > 0 && user_can( $user_id, 'manage_options' ); // Editors, when the site owner turned it on. Anyone without manage_options gets a // limited session in Meow_MWAI_Labs_MCP: content tools only, each call checked // against what that user can do in WordPress. That also covers users admitted // through the filter below. if ( !$allowed && $user_id > 0 && $this->core->get_option( 'mcp_oauth_editors', false ) ) { $allowed = user_can( $user_id, 'edit_others_posts' ); } return (bool) apply_filters( 'mwai_mcp_oauth_user_can_authorize', $allowed, $user_id ); } private function refusal_message() { return $this->core->get_option( 'mcp_oauth_editors', false ) ? 'Only administrators and editors can authorize MCP applications on this site.' : 'Only administrators can authorize MCP applications on this site.'; } #endregion #region Token validation (called from MCP auth path) /** * Validate an access token for protected resource access. * Returns [ 'user_id' => N, 'client_id' => '...', 'scope' => '...' ] on success, null on failure. * Also touches last_used so the admin UI can show recent activity. */ public function validate_token( $token ) { if ( !is_string( $token ) || $token === '' ) { return null; } global $wpdb; $hash = hash( 'sha256', $token ); // Rotated grants still serve their access token; only an explicit revocation // kills it on the spot. $row = $wpdb->get_row( $wpdb->prepare( "SELECT t.*, c.client_name FROM {$this->table_tokens} t LEFT JOIN {$this->table_clients} c ON c.client_id = t.client_id WHERE t.access_token_hash = %s AND t.revoked <> %d LIMIT 1", $hash, self::TOKEN_REVOKED ) ); // A rejected token used to be silent, which made "it works, then it doesn't" // reports impossible to answer: nothing anywhere said why. Each branch below // names the reason, and the marker lets one client's calls be followed across // a log without ever writing a usable credential to disk. if ( !$row ) { if ( $this->logging ) { $marker = $this->token_marker( $token ); $revoked = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$this->table_tokens} WHERE access_token_hash = %s LIMIT 1", $hash ) ); error_log( '[AI Engine MCP OAuth] ❌ Access token ' . $marker . ' rejected: ' . ( $revoked ? 'this grant was revoked (from Connected Apps, or by the client signing out).' : 'no grant matches this token. The client is using a credential this site never issued, or one whose grant has been deleted.' ) ); } return null; } if ( strtotime( $row->access_expires . ' UTC' ) < time() ) { if ( $this->logging ) { error_log( '[AI Engine MCP OAuth] ❌ Access token ' . $this->token_marker( $token ) . ' rejected: it expired on ' . $row->access_expires . ' UTC. The client should refresh it.' ); } return null; } // Touch last_used (non-blocking, single UPDATE). $wpdb->update( $this->table_tokens, [ 'last_used' => current_time( 'mysql', 1 ) ], [ 'id' => $row->id ] ); return [ 'user_id' => (int) $row->user_id, 'client_id' => $row->client_id, 'client_name' => $row->client_name, 'scope' => $row->scope, ]; } #endregion #region Admin: list / revoke grants public function handle_apps_list() { global $wpdb; $rows = $wpdb->get_results( $wpdb->prepare( "SELECT t.id, t.client_id, t.user_id, t.scope, t.created, t.last_used, t.access_expires, t.refresh_expires, t.revoked, c.client_name FROM {$this->table_tokens} t LEFT JOIN {$this->table_clients} c ON c.client_id = t.client_id WHERE t.revoked = 0 AND COALESCE( t.refresh_expires, t.access_expires ) > %s ORDER BY t.id DESC", gmdate( 'Y-m-d H:i:s' ) ) ); // Parallel sessions of one app each hold a live row of the same grant (see // recover_rotated_token): show the grant once, with its latest use. $grants = []; foreach ( $rows as $r ) { $key = $r->client_id . '|' . $r->user_id; if ( !isset( $grants[ $key ] ) ) { $grants[ $key ] = $r; } elseif ( $r->last_used && ( !$grants[ $key ]->last_used || $r->last_used > $grants[ $key ]->last_used ) ) { $grants[ $key ]->last_used = $r->last_used; } } $out = []; foreach ( $grants as $r ) { $user = get_userdata( (int) $r->user_id ); // What this grant can reach right now, so an admin can tell a content-only // Editor session apart, and spot grants that are refused on their next call. $access = 'none'; if ( $user && $this->user_can_authorize( $user->ID ) ) { $access = user_can( $user, 'manage_options' ) ? 'full' : 'content'; if ( $r->scope === self::SCOPE_READ ) { $access = 'readonly'; } } $out[] = [ 'id' => (int) $r->id, 'client_id' => $r->client_id, 'client_name' => $r->client_name ?: 'Unknown app', 'user_id' => (int) $r->user_id, 'user_login' => $user ? $user->user_login : 'deleted', 'user_display' => $user ? $user->display_name : 'Deleted user', 'access' => $access, 'read_only' => $r->scope === self::SCOPE_READ, 'created' => $r->created, 'last_used' => $r->last_used, 'access_expires' => $r->access_expires, 'refresh_expires' => $r->refresh_expires, ]; } return new WP_REST_Response( [ 'apps' => $out ], 200 ); } // An admin may make any connection read-only, but may only give full access back to their // own: raising someone else's grant would hand their account powers they did not approve. public function handle_apps_access( WP_REST_Request $request ) { $id = (int) $request->get_param( 'id' ); $readOnly = rest_sanitize_boolean( $request->get_param( 'read_only' ) ); global $wpdb; $row = $id > 0 ? $wpdb->get_row( $wpdb->prepare( "SELECT client_id, user_id FROM {$this->table_tokens} WHERE id = %d", $id ) ) : null; if ( !$row ) { return new WP_REST_Response( [ 'error' => 'Invalid id.' ], 400 ); } if ( !$readOnly && (int) $row->user_id !== get_current_user_id() ) { return new WP_REST_Response( [ 'error' => 'Only the person who approved this connection can give it full access again.' ], 403 ); } $this->set_grant_scope( $row->client_id, (int) $row->user_id, $readOnly ? self::SCOPE_READ : self::SCOPE_FULL ); return new WP_REST_Response( [ 'read_only' => $readOnly ], 200 ); } public function handle_apps_revoke( WP_REST_Request $request ) { $id = (int) $request->get_param( 'id' ); if ( $id <= 0 ) { return new WP_REST_Response( [ 'error' => 'Invalid id.' ], 400 ); } global $wpdb; // A grant is a chain of rows that can branch (see recover_rotated_token), and any // unexpired rotated row can still refresh: revoke all of them, not just this one. $row = $wpdb->get_row( $wpdb->prepare( "SELECT client_id, user_id FROM {$this->table_tokens} WHERE id = %d", $id ) ); if ( $row ) { $this->revoke_grant( $row->client_id, (int) $row->user_id ); } return new WP_REST_Response( [ 'revoked' => true ], 200 ); } #endregion #region Helpers /** * Drop client registrations that never led to anything. * * A client row is created before the user approves anything, so every abandoned * connection attempt, every diagnostic and every reconnect leaves one behind, and * nothing ever removed them. One user finished a debugging session with a dozen and * no way to clear them short of SQL. * * Only rows with no grant at all, older than a month, are removed: a registration * still waiting for its consent screen after that long is not coming back, and * anything the user actually authorized is untouched whatever its age. This runs on * registration, the only moment new rows appear, so it needs no schedule. */ private function prune_orphan_clients() { global $wpdb; $wpdb->query( $wpdb->prepare( "DELETE c FROM {$this->table_clients} c LEFT JOIN {$this->table_tokens} t ON t.client_id = c.client_id WHERE t.id IS NULL AND c.created < %s", gmdate( 'Y-m-d H:i:s', time() - 30 * DAY_IN_SECONDS ) ) ); } /** * Delete token rows that can never authenticate again: revoked rows whose access token * has expired, and rows whose refresh token has expired. Nothing removed * them before, so every connected app added about 24 rows a day for good. * * A day of grace keeps recent rows around, so the refresh and access logs can still * tell "already rotated or revoked" apart from "never issued" while someone debugs. * Runs whenever a token is issued, so it needs no schedule. */ private function prune_dead_tokens() { global $wpdb; $cutoff = gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS ); // Rotated rows stay until their refresh token expires: another session of the same // client may still hold them (see recover_rotated_token). $wpdb->query( $wpdb->prepare( "DELETE FROM {$this->table_tokens} WHERE access_expires < %s AND ( revoked = %d OR refresh_expires IS NULL OR refresh_expires < %s )", $cutoff, self::TOKEN_REVOKED, $cutoff ) ); } /** Set the access level of every row of a grant (one client, one user), rotated ones included. */ private function set_grant_scope( $client_id, $user_id, $scope ) { global $wpdb; $wpdb->query( $wpdb->prepare( "UPDATE {$this->table_tokens} SET scope = %s WHERE client_id = %s AND user_id = %d", $scope, $client_id, $user_id ) ); } /** Revoke every row of a grant (one client, one user), rotated ones included. */ private function revoke_grant( $client_id, $user_id ) { global $wpdb; $wpdb->query( $wpdb->prepare( "UPDATE {$this->table_tokens} SET revoked = %d WHERE client_id = %s AND user_id = %d", self::TOKEN_REVOKED, $client_id, $user_id ) ); } private function get_client( $client_id ) { if ( !is_string( $client_id ) || $client_id === '' ) { return null; } global $wpdb; return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$this->table_clients} WHERE client_id = %s LIMIT 1", $client_id ) ); } private function redirect_uri_registered( $client, $redirect_uri ) { if ( !$client || !is_string( $redirect_uri ) || $redirect_uri === '' ) { return false; } $registered = json_decode( $client->redirect_uris, true ); if ( !is_array( $registered ) ) { return false; } foreach ( $registered as $uri ) { if ( hash_equals( (string) $uri, $redirect_uri ) ) { return true; } } return false; } private function append_params( $url, $params ) { $sep = strpos( $url, '?' ) === false ? '?' : '&'; return $url . $sep . http_build_query( $params ); } private function random_token( $bytes = 32 ) { return bin2hex( random_bytes( (int) $bytes ) ); } private function oauth_error( $code, $description, $status = 400 ) { $response = new WP_REST_Response( [ 'error' => $code, 'error_description' => $description, ], $status ); $response->header( 'Cache-Control', 'no-store' ); $response->header( 'Pragma', 'no-cache' ); return $response; } /** * Add WWW-Authenticate header to 401 responses on the protected MCP route, * pointing clients at the resource metadata document so they can discover * the authorization server automatically. */ public function add_www_authenticate_header( $response, $server, $request ) { if ( !( $response instanceof WP_HTTP_Response ) ) { return $response; } $route = $request instanceof WP_REST_Request ? $request->get_route() : ''; if ( $route !== '/' . $this->namespace . '/http' ) { return $response; } $status = $response->get_status(); if ( $status !== 401 && $status !== 403 ) { return $response; } // The ?rest_route= form, not rest_url(): many nginx configs deny any path with a "/." // segment (to hide dotfiles), so /wp-json/mcp/v1/.well-known/... answered 404 and ChatGPT // and Claude could not sign in. A query string is never matched by those rules, and it // also works when WordPress lives in a subfolder, unlike the host-root /.well-known path. $resource_metadata = add_query_arg( 'rest_route', '/' . $this->namespace . '/.well-known/oauth-protected-resource', home_url( '/' ) ); $response->header( 'WWW-Authenticate', sprintf( 'Bearer realm="MCP", resource_metadata="%s"', $resource_metadata ) ); return $response; } #endregion #region HTML rendering (consent + error pages) private function render_consent_page( $client, $params, $user ) { $nonce = wp_create_nonce( self::NONCE_ACTION ); $action_url = rest_url( $this->namespace . '/oauth/authorize' ); $site_name = get_bloginfo( 'name' ); $client_name = $client->client_name ?: 'Unnamed MCP Client'; $role_label = $this->describe_user_role( $user ); $destination = $this->describe_redirect( $params['redirect_uri'] ); $impersonated = $this->impersonated_brand( $client_name, $params['redirect_uri'] ); status_header( 200 ); nocache_headers(); header( 'Content-Type: text/html; charset=utf-8' ); // Anyone can register an app (that is how Claude and ChatGPT connect), so the one thing that // stands between an attacker and an admin token is a deliberate click on Approve. Framed in // an invisible iframe on another site, that click could be stolen (Wordfence CVE-2026-110732). $this->send_no_framing_headers(); $hidden_fields = [ 'client_id' => $params['client_id'], 'redirect_uri' => $params['redirect_uri'], 'state' => $params['state'], 'scope' => $params['scope'], 'code_challenge' => $params['code_challenge'], 'code_challenge_method' => $params['code_challenge_method'], 'resource' => $params['resource'], '_mwai_nonce' => $nonce, ]; echo ''; echo ''; echo '' . esc_html( sprintf( 'Authorize %s', $client_name ) ) . ''; echo $this->consent_styles(); echo '
'; echo '

Authorize this app

'; echo '

' . esc_html( $client_name ) . ' wants to connect to ' . esc_html( $site_name ) . '.

'; if ( $impersonated ) { echo '

Careful: this app calls itself ' . esc_html( $impersonated ) . ', but it does not send you back to ' . esc_html( $impersonated ) . '. Someone may be trying to get access to your site. Only approve if you are connecting it yourself, right now, and you know this address.

'; } echo '
'; echo '
Signed in as' . esc_html( $user->display_name ) . ' (' . esc_html( $user->user_login ) . ')
'; echo '
Role' . esc_html( $role_label ) . '
'; echo '
Sends you to' . esc_html( $destination ) . '
'; echo '
'; if ( user_can( $user, 'manage_options' ) ) { echo '

The app will be able to call MCP tools using your account. You can revoke access at any time from AI Engine settings.

'; } else { echo '

The app will work with content only: reading, and writing or editing what your account can already edit in WordPress. Settings, users, plugins, themes and deleting stay with administrators. You can revoke access at any time from AI Engine settings.

'; } echo '
'; foreach ( $hidden_fields as $name => $value ) { echo ''; } echo '
Access'; $readFirst = (bool) $this->core->get_option( 'mcp_default_readonly' ); echo ''; echo ''; echo '
'; echo '
'; echo ''; echo ''; echo '
'; echo '
'; echo '
'; } private function render_error_page( $message ) { status_header( 400 ); nocache_headers(); header( 'Content-Type: text/html; charset=utf-8' ); $this->send_no_framing_headers(); echo ''; echo 'Authorization error'; echo $this->consent_styles(); echo '
'; echo '

Authorization error

'; echo '

' . esc_html( $message ) . '

'; echo '
'; } private function send_no_framing_headers() { header( 'X-Frame-Options: DENY' ); header( "Content-Security-Policy: frame-ancestors 'none'" ); } /** Where the approval sends the browser, as the admin should read it: a host, or the app's scheme. */ private function describe_redirect( $redirect_uri ) { $parts = wp_parse_url( (string) $redirect_uri ); if ( !empty( $parts['host'] ) ) { return $parts['host']; } return !empty( $parts['scheme'] ) ? $parts['scheme'] . '://' : (string) $redirect_uri; } /** * The brand an app name claims (Claude, ChatGPT...) when its redirect does not belong to that * brand, or null. Names are free text at registration, so "Claude" alone proves nothing. * Loopback addresses count as genuine: Claude Code, Cursor and other local clients use them. */ private function impersonated_brand( $client_name, $redirect_uri ) { $brands = [ 'Claude' => [ 'claude', 'anthropic' ], 'ChatGPT' => [ 'chatgpt', 'openai' ], ]; $domains = [ 'Claude' => [ 'claude.ai', 'claude.com', 'anthropic.com' ], 'ChatGPT' => [ 'chatgpt.com', 'openai.com' ], ]; $host = strtolower( (string) wp_parse_url( (string) $redirect_uri, PHP_URL_HOST ) ); if ( $host === '' || in_array( $host, [ 'localhost', '127.0.0.1', '[::1]', '::1' ], true ) ) { return null; } // Torii, our own gateway, connects Claude and ChatGPT on the user's behalf. if ( $host === 'connect.meowapps.com' || substr( $host, -15 ) === '.meowapps.com' ) { return null; } $name = strtolower( (string) $client_name ); foreach ( $brands as $brand => $words ) { foreach ( $words as $word ) { if ( strpos( $name, $word ) === false ) { continue; } foreach ( $domains[ $brand ] as $domain ) { if ( $host === $domain || substr( $host, -strlen( '.' . $domain ) ) === '.' . $domain ) { return null; } } return $brand; } } return null; } private function describe_user_role( $user ) { if ( !$user || empty( $user->roles ) ) { return 'No role'; } $role = $user->roles[0]; $names = [ 'administrator' => 'Administrator', 'editor' => 'Editor (content only)', 'author' => 'Author', 'contributor' => 'Contributor', 'subscriber' => 'Subscriber', ]; return $names[ $role ] ?? ucfirst( $role ); } private function consent_styles() { return ''; } #endregion }