%s %s

', esc_html__( 'ThinkRank MCP: AI assistants will connect but see no tools.', 'thinkrank' ), esc_html__( 'MCP access is enabled, but the bundled Abilities runtime (dependencies/vendor) is missing from this installation — usually a plugin package built without it. Reinstall ThinkRank from wordpress.org or an official build; until then, connected AI clients get an empty tool list.', 'thinkrank' ) ); } /** * Whether the MCP integration is enabled via the admin setting. * * @return bool */ public static function is_enabled(): bool { return (bool) Settings::instance()->get( 'enable_mcp', false ); } // -- Pretty endpoint: /thinkrank/mcp -- /** * Register rewrite rules for the MCP endpoint, OAuth discovery documents, * and the browser-facing authorize page. * * @return void */ public function add_rewrite(): void { // Token-in-URL form: /thinkrank/mcp/ — a single string the user // pastes into their AI client (no separate token field). The bare // /thinkrank/mcp still works with a Bearer token. add_rewrite_rule( '^thinkrank/mcp/([a-f0-9]{64})/?$', 'index.php?' . self::QUERY_VAR . '=1&' . self::TOKEN_QUERY_VAR . '=$matches[1]', 'top' ); add_rewrite_rule( '^thinkrank/mcp/?$', 'index.php?' . self::QUERY_VAR . '=1', 'top' ); // OAuth discovery documents. RFC 9728 §3.1 / RFC 8414 §3.1 place the // `.well-known` segment BEFORE the resource path, so our resource at // /thinkrank/mcp is discovered at the path-suffixed form: // /.well-known/oauth-protected-resource/thinkrank/mcp // /.well-known/oauth-authorization-server/thinkrank/mcp // The OAuth issuer is the path-based identifier home_url('/thinkrank/mcp') // (see Mcp_OAuth::issuer), so spec-compliant clients derive exactly // these URLs — and the rule stays specific to OUR path. That matters // for coexistence: another plugin serving its own MCP OAuth surface // (e.g. xSpeed) claims the generic `(?:/.*)?` root rule, and rewrite // rules are keyed by regex, so a shared broad rule would be silently // overwritten by whichever plugin registers last. add_rewrite_rule( '^\.well-known/oauth-(protected-resource|authorization-server)/thinkrank/mcp/?$', 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]', 'top' ); // Root-form fallback for clients that only try the bare well-known // URL. Harmless when another plugin also registers this exact regex — // last registrant wins, and our clients use the path-suffixed form. add_rewrite_rule( '^\.well-known/oauth-(protected-resource|authorization-server)(?:/.*)?/?$', 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]', 'top' ); // Suffix form: /.well-known/... . RFC 8414 specifies the // path-INSERT form above, but the older OpenID Connect Discovery // convention appends instead, and clients built on an OIDC library // try that shape first (sometimes only that shape). Serving both // costs two rules and removes a whole class of "server does not // implement OAuth" failures from clients that never fall back. add_rewrite_rule( '^thinkrank/mcp/\.well-known/oauth-(protected-resource|authorization-server)/?$', 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]', 'top' ); add_rewrite_rule( '^thinkrank/mcp/\.well-known/openid-configuration/?$', 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=authorization-server', 'top' ); // Browser-facing OAuth consent page — served OUTSIDE REST so cookie // auth (is_user_logged_in) works after the wp-login round-trip. add_rewrite_rule( '^thinkrank/authorize/?$', 'index.php?' . self::AUTHORIZE_QUERY_VAR . '=1', 'top' ); // Self-heal: flush once if ANY of our rules is missing from the stored // rewrite table, so the endpoints work without a manual permalink // re-save (and newly added rules trigger a re-flush on upgrade). $expected = [ '^thinkrank/mcp/([a-f0-9]{64})/?$', '^thinkrank/mcp/?$', '^\.well-known/oauth-(protected-resource|authorization-server)/thinkrank/mcp/?$', '^thinkrank/mcp/\.well-known/oauth-(protected-resource|authorization-server)/?$', '^thinkrank/mcp/\.well-known/openid-configuration/?$', '^thinkrank/authorize/?$', ]; $rules = get_option( 'rewrite_rules' ); if ( is_array( $rules ) ) { foreach ( $expected as $rule ) { if ( ! isset( $rules[ $rule ] ) ) { flush_rewrite_rules( false ); break; } } } } /** * Register our query vars. * * @param string[] $vars Registered query vars. * @return string[] */ public function register_query_var( array $vars ): array { $vars[] = self::QUERY_VAR; $vars[] = self::TOKEN_QUERY_VAR; $vars[] = self::WELLKNOWN_QUERY_VAR; $vars[] = self::AUTHORIZE_QUERY_VAR; return $vars; } /** * Serve the MCP endpoint on the pretty path. Runs on parse_request so it * fires before the main query, and short-circuits WP entirely. * * @param \WP $wp The WP request object. * @return void */ public function maybe_handle_pretty_endpoint( $wp ): void { // OAuth discovery documents (served at the site root). if ( ! empty( $wp->query_vars[ self::WELLKNOWN_QUERY_VAR ] ) ) { if ( ! self::is_enabled() ) { status_header( 404 ); exit; } $doc = (string) $wp->query_vars[ self::WELLKNOWN_QUERY_VAR ]; $data = 'authorization-server' === $doc ? Mcp_OAuth::authorization_server_metadata() : Mcp_OAuth::protected_resource_metadata(); status_header( 200 ); header( 'Content-Type: application/json; charset=utf-8' ); // Discovery metadata is public + cacheable. header( 'Cache-Control: public, max-age=3600' ); echo wp_json_encode( $data ); exit; } // Browser-facing OAuth consent page (cookie auth applies here). if ( ! empty( $wp->query_vars[ self::AUTHORIZE_QUERY_VAR ] ) ) { if ( ! self::is_enabled() ) { status_header( 404 ); exit; } $this->handle_authorize_page(); return; } if ( empty( $wp->query_vars[ self::QUERY_VAR ] ) ) { return; } $request = new \WP_REST_Request( 'POST', '/' . self::NS . '/mcp' ); $request->set_header( 'content-type', 'application/json' ); // Carry the auth header + raw body from the live PHP request. $auth = self::server_header( 'authorization' ); if ( null !== $auth ) { $request->set_header( 'authorization', $auth ); } // Token embedded in the URL path (/thinkrank/mcp/) — surface it // as a Bearer header so Mcp_Server validates it the same way. A real // Authorization header (if also sent) takes precedence. $path_token = isset( $wp->query_vars[ self::TOKEN_QUERY_VAR ] ) ? (string) $wp->query_vars[ self::TOKEN_QUERY_VAR ] : ''; if ( '' !== $path_token && '' === (string) $request->get_header( 'authorization' ) ) { $request->set_header( 'authorization', 'Bearer ' . $path_token ); } $request->set_body( (string) file_get_contents( 'php://input' ) ); $response = Mcp_Server::handle( $request ); $this->emit_json( $response ); } // -- REST registration -- /** * Register the REST routes: the MCP JSON-RPC fallback, the admin * management routes, and the OAuth registration/token endpoints. * * @return void */ public function register_rest(): void { // --- MCP JSON-RPC endpoint (fallback path via wp-json) ----------- // permission_callback is __return_true because Mcp_Server does its own // token auth and must reply with a JSON-RPC 401 + WWW-Authenticate, // not a bare WP permission failure. register_rest_route( self::NS, '/mcp', [ 'methods' => 'POST', 'callback' => [ $this, 'rest_mcp' ], 'permission_callback' => '__return_true', ] ); // --- Admin-only management routes (the MCP page) ------------------ register_rest_route( self::NS, '/mcp/connection', [ 'methods' => 'GET', 'callback' => [ $this, 'rest_connection' ], 'permission_callback' => [ $this, 'admin_permission' ], ] ); register_rest_route( self::NS, '/mcp/connect', [ 'methods' => 'POST', 'callback' => [ $this, 'rest_connect' ], 'permission_callback' => [ $this, 'admin_permission' ], 'args' => [ 'read_only' => [ 'type' => 'boolean', 'required' => false, 'default' => false, 'description' => 'Grant read-only access (no SEO metadata or settings changes).', ], ], ] ); register_rest_route( self::NS, '/mcp/rotate', [ 'methods' => 'POST', 'callback' => [ $this, 'rest_rotate' ], 'permission_callback' => [ $this, 'admin_permission' ], 'args' => [ 'read_only' => [ 'type' => 'boolean', 'required' => false, 'description' => 'Optionally set read-only on the new token; omit to keep current scopes.', ], ], ] ); register_rest_route( self::NS, '/mcp/disconnect', [ 'methods' => 'POST', 'callback' => [ $this, 'rest_disconnect' ], 'permission_callback' => [ $this, 'admin_permission' ], ] ); // Live round-trip diagnostic for the MCP page (see #189). Admin-only; // exercises the endpoint the way an external client would. register_rest_route( self::NS, '/mcp/self-test', [ 'methods' => 'POST', 'callback' => [ $this, 'rest_self_test' ], 'permission_callback' => [ $this, 'admin_permission' ], ] ); // Connected AI apps (see #244): list the OAuth-connected clients plus a // single combined row for the shared static token, and revoke either. register_rest_route( self::NS, '/mcp/apps', [ 'methods' => 'GET', 'callback' => [ $this, 'rest_apps' ], 'permission_callback' => [ $this, 'admin_permission' ], ] ); register_rest_route( self::NS, '/mcp/apps/revoke', [ 'methods' => 'POST', 'callback' => [ $this, 'rest_revoke_app' ], 'permission_callback' => [ $this, 'admin_permission' ], 'args' => [ 'client_id' => [ 'type' => 'string', 'required' => true, 'description' => 'The OAuth client_id to revoke.', ], ], ] ); // --- OAuth 2.1 authorization server (the "paste a URL only" path) - // Discovery, dynamic client registration, and the token endpoint are // all public (permission enforced inside): a client must reach them // BEFORE it holds any credential. // // The discovery documents are ALSO served here, not only at the // /.well-known/ rewrites: hosts that resolve root /.well-known/ at // their proxy edge (SiteGround) never let those requests reach // WordPress, while /wp-json/ always arrives. The 401 challenge // advertises this route (Mcp_OAuth::resource_metadata_url), so the // flow survives on such hosts. register_rest_route( self::NS, '/mcp/oauth/protected-resource', [ 'methods' => 'GET', 'callback' => [ $this, 'rest_oauth_discovery_resource' ], 'permission_callback' => '__return_true', ] ); register_rest_route( self::NS, '/mcp/oauth/authorization-server', [ 'methods' => 'GET', 'callback' => [ $this, 'rest_oauth_discovery_server' ], 'permission_callback' => '__return_true', ] ); register_rest_route( self::NS, '/mcp/oauth/register', [ 'methods' => 'POST', 'callback' => [ $this, 'rest_oauth_register' ], 'permission_callback' => '__return_true', ] ); // NOTE: /authorize is deliberately NOT a REST route — it is served as // a normal front-end page at /thinkrank/authorize (see // handle_authorize_page) so cookie auth works after wp-login. register_rest_route( self::NS, '/mcp/oauth/token', [ 'methods' => 'POST', 'callback' => [ $this, 'rest_oauth_token' ], 'permission_callback' => '__return_true', ] ); } /** * Capability gate for the admin-only management routes. * * @return bool */ public function admin_permission(): bool { return current_user_can( 'manage_options' ); } // -- Handlers ---------------------------------------------------------- /** * MCP JSON-RPC over the wp-json fallback path. * * @param \WP_REST_Request $request Incoming request. * @return \WP_REST_Response */ public function rest_mcp( \WP_REST_Request $request ): \WP_REST_Response { $response = Mcp_Server::handle( $request ); // Advertise the MCP protocol version on the wp-json transport too, so // both endpoints behave identically to a strict Streamable-HTTP client. $response->header( 'MCP-Protocol-Version', Mcp_Server::PROTOCOL_VERSION ); return $response; } /** * GET /mcp/connection — pairing status for the MCP page. * * @return \WP_REST_Response */ public function rest_connection(): \WP_REST_Response { $this->ensure_connected(); return rest_ensure_response( Mcp_Pairing::public_status() ); } /** * Self-heal: whenever the admin views the MCP page with MCP enabled, make * sure a connection token exists. New sites mint on the enable toggle (see * #244), but a site that had MCP on before that behavior shipped would have * no token; minting here — idempotent, admin-gated — keeps the connect * recipes populated without a separate "Generate token" click. * * @return void */ private function ensure_connected(): void { if ( self::is_enabled() && ! Mcp_Pairing::is_connected() ) { Mcp_Pairing::connect(); } } /** * POST /mcp/connect — mint a connection token. * * @param \WP_REST_Request $request Carries optional read_only. * @return \WP_REST_Response */ public function rest_connect( \WP_REST_Request $request ): \WP_REST_Response { $read_only = (bool) $request->get_param( 'read_only' ); return rest_ensure_response( Mcp_Pairing::connect( $read_only ) ); } /** * POST /mcp/rotate — mint a fresh token, invalidating the old one. * * @param \WP_REST_Request $request Carries optional read_only. * @return \WP_REST_Response */ public function rest_rotate( \WP_REST_Request $request ): \WP_REST_Response { $read_only = null; if ( null !== $request->get_param( 'read_only' ) ) { $read_only = (bool) $request->get_param( 'read_only' ); } return rest_ensure_response( Mcp_Pairing::rotate( $read_only ) ); } /** * POST /mcp/disconnect — revoke the connection token + all OAuth grants. * * @return \WP_REST_Response */ public function rest_disconnect(): \WP_REST_Response { return rest_ensure_response( Mcp_Pairing::disconnect() ); } /** * POST /mcp/self-test — run the live round-trip diagnostic (see #189). * * @return \WP_REST_Response */ public function rest_self_test(): \WP_REST_Response { return rest_ensure_response( Mcp_Self_Test::run() ); } /** * GET /mcp/apps — the "Connected AI apps" list (see #244). * * @return \WP_REST_Response */ public function rest_apps(): \WP_REST_Response { $this->ensure_connected(); return rest_ensure_response( $this->apps_payload() ); } /** * POST /mcp/apps/revoke — cut off a single OAuth-connected app. Returns the * refreshed app list so the UI updates in one round trip. (The shared static * token has no per-client identity, so it is not listed or revoked here — it * is rotated from the connect card via /mcp/rotate.) * * @param \WP_REST_Request $request Carries target + client_id. * @return \WP_REST_Response|\WP_Error */ public function rest_revoke_app( \WP_REST_Request $request ) { $client_id = (string) $request->get_param( 'client_id' ); if ( '' === $client_id ) { return new \WP_Error( 'thinkrank_missing_client_id', __( 'A client_id is required to revoke an OAuth app.', 'thinkrank' ), [ 'status' => 400 ] ); } Mcp_OAuth::revoke_client( $client_id ); return rest_ensure_response( $this->apps_payload() ); } /** * Build the "Connected AI apps" payload: the OAuth-connected clients, with * the approving admin's display name resolved. Header-based (static-token) * clients share one anonymous secret and so are not represented here. * * @return array */ private function apps_payload(): array { $oauth_apps = []; foreach ( Mcp_OAuth::connected_apps() as $app ) { $user = $app['user_id'] > 0 ? get_userdata( $app['user_id'] ) : false; $oauth_apps[] = [ 'client_id' => $app['client_id'], 'name' => $app['name'], 'read_only' => $app['read_only'], 'approved_by' => $user ? $user->display_name : __( 'Unknown user', 'thinkrank' ), 'connected_at' => $app['connected_at'], 'last_used' => $app['last_used'], ]; } return [ 'oauth_apps' => $oauth_apps, ]; } // -- OAuth 2.1 handlers ------------------------------------------------ /** * GET /mcp/oauth/protected-resource — RFC 9728 metadata via REST. * * @return \WP_REST_Response|\WP_Error */ public function rest_oauth_discovery_resource() { return $this->oauth_discovery_response( Mcp_OAuth::protected_resource_metadata() ); } /** * GET /mcp/oauth/authorization-server — RFC 8414 metadata via REST. * * @return \WP_REST_Response|\WP_Error */ public function rest_oauth_discovery_server() { return $this->oauth_discovery_response( Mcp_OAuth::authorization_server_metadata() ); } /** * Shape one discovery document response: public, cacheable, and 404 when * MCP is off — matching the /.well-known/ rewrites exactly, so a client * sees the same truth regardless of which serving path reached it. * * @param array $document Discovery metadata. * @return \WP_REST_Response|\WP_Error */ private function oauth_discovery_response( array $document ) { if ( ! self::is_enabled() ) { return new \WP_Error( 'thinkrank_mcp_disabled', __( 'MCP is disabled on this site.', 'thinkrank' ), [ 'status' => 404 ] ); } $response = new \WP_REST_Response( $document, 200 ); $response->header( 'Cache-Control', 'public, max-age=3600' ); return $response; } /** * POST /mcp/oauth/register — RFC 7591 dynamic client registration. * * @param \WP_REST_Request $request JSON body with redirect_uris. * @return \WP_REST_Response|\WP_Error */ public function rest_oauth_register( \WP_REST_Request $request ) { if ( ! self::is_enabled() ) { return new \WP_Error( 'thinkrank_mcp_disabled', __( 'MCP is disabled on this site.', 'thinkrank' ), [ 'status' => 403 ] ); } $body = $request->get_json_params(); if ( ! is_array( $body ) ) { $body = []; } $result = Mcp_OAuth::register_client( $body ); if ( is_wp_error( $result ) ) { return $result; } return new \WP_REST_Response( $result, 201 ); } /** * POST /mcp/oauth/token — exchange a code (or refresh token) for tokens. * * @param \WP_REST_Request $request Form-encoded or JSON token request. * @return \WP_REST_Response */ public function rest_oauth_token( \WP_REST_Request $request ): \WP_REST_Response { if ( ! self::is_enabled() ) { $response = new \WP_REST_Response( [ 'error' => 'invalid_request', 'error_description' => 'MCP is disabled on this site.', ], 403 ); $response->header( 'Cache-Control', 'no-store' ); return $response; } // Token requests are application/x-www-form-urlencoded per OAuth, but // accept JSON too. get_body_params() covers the form case. $body = $request->get_body_params(); if ( empty( $body ) ) { $json = $request->get_json_params(); $body = is_array( $json ) ? $json : []; } $body = array_map( 'strval', $body ); $result = Mcp_OAuth::exchange_token( $body ); if ( is_wp_error( $result ) ) { $data = $result->get_error_data(); $response = new \WP_REST_Response( [ 'error' => isset( $data['error'] ) ? $data['error'] : 'invalid_request', 'error_description' => isset( $data['error_description'] ) ? $data['error_description'] : $result->get_error_message(), ], isset( $data['status'] ) ? (int) $data['status'] : 400 ); $response->header( 'Cache-Control', 'no-store' ); return $response; } $response = new \WP_REST_Response( $result, 200 ); $response->header( 'Cache-Control', 'no-store' ); $response->header( 'Pragma', 'no-cache' ); return $response; } // -- OAuth authorize page ------------------------------------------------ /** * The browser-facing OAuth authorize page (served at /thinkrank/authorize * via a rewrite, NOT the REST API). Reads request params from the * superglobals because this is a normal front-end request where cookie * auth populates is_user_logged_in(). * * GET renders the consent screen (requires a logged-in admin; anonymous * users go to wp-login and return here). POST is the nonce-checked consent * submission: Approve issues a code and 302s to the client's redirect_uri; * Deny 302s back with error=access_denied. Always emits its own response * (HTML page or redirect) and exits. * * @return void */ public function handle_authorize_page(): void { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- compared against a literal after strtoupper(); nothing is stored or echoed. $is_post = isset( $_SERVER['REQUEST_METHOD'] ) && 'POST' === strtoupper( (string) wp_unslash( $_SERVER['REQUEST_METHOD'] ) ); // Params come from GET on the consent link and POST on the form submit. // Nonce is verified below before any POST value is acted on. // phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each member is sanitize_text_field()ed in the loop below; nothing reads $source directly. $source = $is_post ? $_POST : $_GET; // phpcs:enable WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized $params = []; foreach ( [ 'client_id', 'redirect_uri', 'response_type', 'code_challenge', 'code_challenge_method', 'scope', 'state', 'approve', 'deny', '_thinkrank_oauth_nonce' ] as $k ) { $params[ $k ] = isset( $source[ $k ] ) ? sanitize_text_field( wp_unslash( $source[ $k ] ) ) : ''; } // Validate the OAuth params before touching the session. $req = Mcp_OAuth::validate_authorize_request( $params ); if ( is_wp_error( $req ) ) { $data = $req->get_error_data(); $redirectable = is_array( $data ) && ! empty( $data['redirectable'] ); // Only redirect the error back when redirect_uri is verified valid; // otherwise show a page (never bounce to an unverified URL). if ( $redirectable && '' !== $params['redirect_uri'] ) { $this->redirect_error( $params['redirect_uri'], $req->get_error_code(), $req->get_error_message(), $params['state'] ); } $this->emit_oauth_error_page( $req->get_error_message() ); } // Require a logged-in admin. Anonymous → wp-login, back to this URL. if ( ! is_user_logged_in() ) { $this->redirect_to_login(); } if ( ! current_user_can( 'manage_options' ) ) { $this->emit_oauth_error_page( __( 'You must be an administrator to authorize an AI assistant to manage SEO on this site.', 'thinkrank' ) ); } // POST = consent form submitted. if ( $is_post ) { if ( ! wp_verify_nonce( $params['_thinkrank_oauth_nonce'], 'thinkrank_oauth_consent' ) ) { $this->emit_oauth_error_page( __( 'Security check failed. Please try connecting again.', 'thinkrank' ) ); } if ( '' === $params['approve'] ) { $this->redirect_error( $req['redirect_uri'], 'access_denied', 'The user denied the request.', $req['state'] ); } $code = Mcp_OAuth::issue_code( $req, get_current_user_id() ); $this->redirect_success( $req['redirect_uri'], $code, $req['state'] ); } // GET = render the consent screen. $this->emit_consent_screen( $req ); } // -- OAuth browser-response helpers ------------------------------------ /** * The absolute URL of the current authorize request (for login return). * * @return string */ private function current_authorize_url(): string { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- reconstructing the current URL for a login round-trip; escaped at use. $uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : ''; return home_url( $uri ); } /** * Send an anonymous visitor to wp-login, returning to this authorize URL. * * @return void */ private function redirect_to_login(): void { wp_safe_redirect( wp_login_url( $this->current_authorize_url() ) ); exit; } /** * 302 back to the client with the authorization code (+ state). * * @param string $redirect_uri Validated client redirect URI. * @param string $code Authorization code. * @param string $state Client state. * @return void */ private function redirect_success( string $redirect_uri, string $code, string $state ): void { $args = [ 'code' => $code ]; if ( '' !== $state ) { $args['state'] = $state; } // Not wp_safe_redirect: redirect_uri is a client-registered off-site // callback, already validated against the client's registered set. wp_redirect( add_query_arg( $args, $redirect_uri ) ); // phpcs:ignore WordPress.Security.SafeRedirect -- validated OAuth redirect_uri. exit; } /** * 302 back to the client with an OAuth error (+ state). * * @param string $redirect_uri Validated client redirect URI. * @param string $error OAuth error code. * @param string $description Human-readable description. * @param string $state Client state. * @return void */ private function redirect_error( string $redirect_uri, string $error, string $description, string $state ): void { $args = [ 'error' => $error, 'error_description' => $description, ]; if ( '' !== $state ) { $args['state'] = $state; } wp_redirect( add_query_arg( array_map( 'rawurlencode', $args ), $redirect_uri ) ); // phpcs:ignore WordPress.Security.SafeRedirect -- validated OAuth redirect_uri. exit; } /** * Render the consent screen. Minimal self-contained HTML (no admin * chrome — this is a client-facing OAuth page). Approve/Deny post back * to the same authorize URL with a nonce. * * @param array $req Validated authorize params. * @return void */ private function emit_consent_screen( array $req ): void { $read_only = Mcp_OAuth::scope_is_read_only( $req['scope'] ); $access_label = $read_only ? __( 'Read-only', 'thinkrank' ) : __( 'Read & write', 'thinkrank' ); $access_desc = $read_only ? __( 'Review your SEO across posts and site settings — metadata, schema, sitemaps, robots, social, and SEO scores. No changes are made.', 'thinkrank' ) : __( 'Read and improve your SEO across posts and site settings — metadata, schema, sitemaps, robots, social, indexing, and SEO scores.', 'thinkrank' ); $client = '' !== $req['client_name'] ? $req['client_name'] : __( 'An AI assistant', 'thinkrank' ); $action_url = Mcp_OAuth::authorize_url(); $nonce = wp_create_nonce( 'thinkrank_oauth_consent' ); $user = wp_get_current_user(); // Preserve every OAuth param so the POST re-validates identically. $hidden = ''; foreach ( [ 'client_id', 'redirect_uri', 'code_challenge', 'scope', 'state' ] as $k ) { $val = 'scope' === $k ? $req['scope'] : ( $req[ $k ] ?? '' ); $hidden .= sprintf( '', esc_attr( $k ), esc_attr( (string) $val ) ); } // code_challenge_method + response_type are re-asserted for validation. $hidden .= ''; $hidden .= ''; status_header( 200 ); header( 'Content-Type: text/html; charset=utf-8' ); header( 'Cache-Control: no-store' ); $host = (string) wp_parse_url( home_url(), PHP_URL_HOST ); $logo = ''; $lock = ''; echo '' . esc_html__( 'Authorize AI access', 'thinkrank' ) . ''; echo '
'; echo '
ThinkRank
'; // phpcs:ignore WordPress.Security.EscapeOutput -- static markup. echo '

' . esc_html__( 'Connect to ThinkRank', 'thinkrank' ) . '

'; $sub = sprintf( /* translators: %s: AI client name, already escaped and wrapped in . */ esc_html__( '%s wants to manage SEO on this site.', 'thinkrank' ), '' . esc_html( $client ) . '' ); echo '

' . $sub . '

'; // phpcs:ignore WordPress.Security.EscapeOutput -- static translation; client name esc_html'd. echo '
'; echo '
' . esc_html__( 'Site', 'thinkrank' ) . '' . esc_html( $host ) . '
'; echo '
' . esc_html__( 'Signed in as', 'thinkrank' ) . '' . esc_html( $user->user_login ) . '
'; echo '
' . esc_html__( 'Access', 'thinkrank' ) . '
'; echo '' . esc_html( $access_label ) . ''; echo '
' . esc_html( $access_desc ) . '
'; echo '
'; echo '

' . $lock . '' . esc_html__( 'Secured with OAuth. Revoke anytime in ThinkRank → MCP.', 'thinkrank' ) . '

'; // phpcs:ignore WordPress.Security.EscapeOutput -- static icon; text esc_html'd. echo '
'; echo $hidden; // phpcs:ignore WordPress.Security.EscapeOutput -- built from esc_attr() above. echo ''; echo '
'; echo ''; echo ''; echo '
'; exit; } /** * Render a standalone OAuth error page (no redirect). * * @param string $message Error message. * @return void */ private function emit_oauth_error_page( string $message ): void { status_header( 400 ); header( 'Content-Type: text/html; charset=utf-8' ); header( 'Cache-Control: no-store' ); echo '' . esc_html__( 'Authorization error', 'thinkrank' ) . ''; echo ''; echo '

' . esc_html__( 'Could not authorize', 'thinkrank' ) . '

' . esc_html( $message ) . '

'; exit; } // -- Helpers -- /** * Read an inbound HTTP header from $_SERVER (for the pretty path). * * @param string $name Header name. * @return string|null */ private static function server_header( string $name ): ?string { $key = 'HTTP_' . strtoupper( str_replace( '-', '_', $name ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- token compared constant-time downstream; raw header needed verbatim. return isset( $_SERVER[ $key ] ) ? wp_unslash( $_SERVER[ $key ] ) : null; } /** * Emit a WP_REST_Response as a JSON HTTP response and stop. * * @param \WP_REST_Response $response Response to emit. * @return void */ private function emit_json( \WP_REST_Response $response ): void { status_header( $response->get_status() ); // MCP Streamable HTTP: advertise the protocol version we speak so a // strict client can pin it. We answer JSON (a spec-permitted response // type); we never open an SSE stream, so no session header is needed. header( 'MCP-Protocol-Version: ' . Mcp_Server::PROTOCOL_VERSION ); // Forward any headers the handler set (notably WWW-Authenticate on a // 401, which drives the OAuth discovery flow). foreach ( $response->get_headers() as $name => $value ) { // Re-assert the status on every header: PHP special-cases // WWW-Authenticate and forces a 401 when no status is given, // which would silently mask the 429 lockout response. header( $name . ': ' . $value, true, $response->get_status() ); } $data = $response->get_data(); if ( null !== $data ) { header( 'Content-Type: application/json; charset=utf-8' ); echo wp_json_encode( $data ); } exit; } }