PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.25.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.25.0
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
thinkrank / includes / mcp / class-mcp-manager.php

class-mcp-manager.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 1.25.0, at includes/mcp/class-mcp-manager.php

802 lines 35.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * MCP manager — the site side of ThinkRank's MCP integration.
4 *
5 * The plugin speaks the MCP protocol DIRECTLY at this site's own URL. The
6 * user pastes their own site's MCP endpoint + connection token into their AI
7 * client — or, for OAuth-capable clients (claude.ai remote connectors), just
8 * the URL:
9 *
10 * https://thissite.com/thinkrank/mcp (pretty, via rewrite)
11 * https://thissite.com/wp-json/thinkrank/v1/mcp (always-on fallback)
12 *
13 * The MCP JSON-RPC handling lives in Mcp_Server; the tool surface is the
14 * abilities registry (Mcp_Tools). Auth is the per-site connection token
15 * (Mcp_Pairing) or an OAuth 2.1 access token (Mcp_OAuth).
16 *
17 * Admin-only management routes (manage_options) drive the MCP page:
18 * /mcp/connection, /mcp/connect, /mcp/rotate, /mcp/disconnect.
19 *
20 * The token-only MCP + OAuth routes are exempted from Role_Manager's
21 * namespace-wide capability gate (they authenticate inside the handler); the
22 * exemption lives in Role_Manager::gate_rest().
23 *
24 * A single admin toggle (`enable_mcp`) is the master switch: when off, the
25 * MCP endpoint, discovery documents, and OAuth endpoints all refuse to serve.
26 *
27 * @package ThinkRank\Mcp
28 */
29
30 declare(strict_types=1);
31
32 namespace ThinkRank\Mcp;
33
34 use ThinkRank\Core\Settings;
35
36 if ( ! defined( 'ABSPATH' ) ) {
37 exit; // Exit if accessed directly.
38 }
39
40 /**
41 * Registers the MCP endpoint, OAuth discovery/authorize/token surface, and
42 * the admin management routes.
43 */
44 final class Mcp_Manager {
45
46 /**
47 * REST namespace shared with the rest of the plugin.
48 */
49 private const NS = 'thinkrank/v1';
50
51 /**
52 * Query var flagging a pretty /thinkrank/mcp request.
53 */
54 private const QUERY_VAR = 'thinkrank_mcp';
55
56 /**
57 * Query var carrying the token when embedded in the URL path.
58 */
59 private const TOKEN_QUERY_VAR = 'thinkrank_mcp_token';
60
61 /**
62 * Query var flagging a /.well-known/ OAuth discovery request.
63 */
64 private const WELLKNOWN_QUERY_VAR = 'thinkrank_mcp_wellknown';
65
66 /**
67 * Query var flagging the browser-facing OAuth authorize page. This is
68 * served OUTSIDE the REST API on purpose: a REST route only honors cookie
69 * auth when a REST nonce accompanies it, but a browser arriving from
70 * wp-login carries the cookie with NO nonce — so is_user_logged_in()
71 * would be false there and the consent screen would loop back to login
72 * forever. A normal front-end URL (rewrite + parse_request) sees standard
73 * cookie auth, so the logged-in admin check works.
74 */
75 private const AUTHORIZE_QUERY_VAR = 'thinkrank_mcp_authorize';
76
77 /**
78 * Initialize (called by the plugin's component container).
79 *
80 * @return void
81 */
82 public function init(): void {
83 add_action( 'rest_api_init', [ $this, 'register_rest' ] );
84
85 // Pretty per-site endpoint: /thinkrank/mcp → MCP JSON-RPC handler.
86 add_action( 'init', [ $this, 'add_rewrite' ] );
87 add_filter( 'query_vars', [ $this, 'register_query_var' ] );
88 add_action( 'parse_request', [ $this, 'maybe_handle_pretty_endpoint' ] );
89 }
90
91 /**
92 * Whether the MCP integration is enabled via the admin setting.
93 *
94 * @return bool
95 */
96 public static function is_enabled(): bool {
97 return (bool) Settings::instance()->get( 'enable_mcp', false );
98 }
99
100 // -- Pretty endpoint: /thinkrank/mcp --
101
102 /**
103 * Register rewrite rules for the MCP endpoint, OAuth discovery documents,
104 * and the browser-facing authorize page.
105 *
106 * @return void
107 */
108 public function add_rewrite(): void {
109 // Token-in-URL form: /thinkrank/mcp/<token> — a single string the user
110 // pastes into their AI client (no separate token field). The bare
111 // /thinkrank/mcp still works with a Bearer token.
112 add_rewrite_rule(
113 '^thinkrank/mcp/([a-f0-9]{64})/?$',
114 'index.php?' . self::QUERY_VAR . '=1&' . self::TOKEN_QUERY_VAR . '=$matches[1]',
115 'top'
116 );
117 add_rewrite_rule( '^thinkrank/mcp/?$', 'index.php?' . self::QUERY_VAR . '=1', 'top' );
118
119 // OAuth discovery documents. RFC 9728 §3.1 / RFC 8414 §3.1 place the
120 // `.well-known` segment BEFORE the resource path, so our resource at
121 // /thinkrank/mcp is discovered at the path-suffixed form:
122 // /.well-known/oauth-protected-resource/thinkrank/mcp
123 // /.well-known/oauth-authorization-server/thinkrank/mcp
124 // The OAuth issuer is the path-based identifier home_url('/thinkrank/mcp')
125 // (see Mcp_OAuth::issuer), so spec-compliant clients derive exactly
126 // these URLs — and the rule stays specific to OUR path. That matters
127 // for coexistence: another plugin serving its own MCP OAuth surface
128 // (e.g. xSpeed) claims the generic `(?:/.*)?` root rule, and rewrite
129 // rules are keyed by regex, so a shared broad rule would be silently
130 // overwritten by whichever plugin registers last.
131 add_rewrite_rule(
132 '^\.well-known/oauth-(protected-resource|authorization-server)/thinkrank/mcp/?$',
133 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]',
134 'top'
135 );
136 // Root-form fallback for clients that only try the bare well-known
137 // URL. Harmless when another plugin also registers this exact regex —
138 // last registrant wins, and our clients use the path-suffixed form.
139 add_rewrite_rule(
140 '^\.well-known/oauth-(protected-resource|authorization-server)(?:/.*)?/?$',
141 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]',
142 'top'
143 );
144
145 // Browser-facing OAuth consent page — served OUTSIDE REST so cookie
146 // auth (is_user_logged_in) works after the wp-login round-trip.
147 add_rewrite_rule( '^thinkrank/authorize/?$', 'index.php?' . self::AUTHORIZE_QUERY_VAR . '=1', 'top' );
148
149 // Self-heal: flush once if ANY of our rules is missing from the stored
150 // rewrite table, so the endpoints work without a manual permalink
151 // re-save (and newly added rules trigger a re-flush on upgrade).
152 $expected = [
153 '^thinkrank/mcp/([a-f0-9]{64})/?$',
154 '^thinkrank/mcp/?$',
155 '^\.well-known/oauth-(protected-resource|authorization-server)/thinkrank/mcp/?$',
156 '^thinkrank/authorize/?$',
157 ];
158 $rules = get_option( 'rewrite_rules' );
159 if ( is_array( $rules ) ) {
160 foreach ( $expected as $rule ) {
161 if ( ! isset( $rules[ $rule ] ) ) {
162 flush_rewrite_rules( false );
163 break;
164 }
165 }
166 }
167 }
168
169 /**
170 * Register our query vars.
171 *
172 * @param string[] $vars Registered query vars.
173 * @return string[]
174 */
175 public function register_query_var( array $vars ): array {
176 $vars[] = self::QUERY_VAR;
177 $vars[] = self::TOKEN_QUERY_VAR;
178 $vars[] = self::WELLKNOWN_QUERY_VAR;
179 $vars[] = self::AUTHORIZE_QUERY_VAR;
180 return $vars;
181 }
182
183 /**
184 * Serve the MCP endpoint on the pretty path. Runs on parse_request so it
185 * fires before the main query, and short-circuits WP entirely.
186 *
187 * @param \WP $wp The WP request object.
188 * @return void
189 */
190 public function maybe_handle_pretty_endpoint( $wp ): void {
191 // OAuth discovery documents (served at the site root).
192 if ( ! empty( $wp->query_vars[ self::WELLKNOWN_QUERY_VAR ] ) ) {
193 if ( ! self::is_enabled() ) {
194 status_header( 404 );
195 exit;
196 }
197 $doc = (string) $wp->query_vars[ self::WELLKNOWN_QUERY_VAR ];
198 $data = 'authorization-server' === $doc
199 ? Mcp_OAuth::authorization_server_metadata()
200 : Mcp_OAuth::protected_resource_metadata();
201 status_header( 200 );
202 header( 'Content-Type: application/json; charset=utf-8' );
203 // Discovery metadata is public + cacheable.
204 header( 'Cache-Control: public, max-age=3600' );
205 echo wp_json_encode( $data );
206 exit;
207 }
208
209 // Browser-facing OAuth consent page (cookie auth applies here).
210 if ( ! empty( $wp->query_vars[ self::AUTHORIZE_QUERY_VAR ] ) ) {
211 if ( ! self::is_enabled() ) {
212 status_header( 404 );
213 exit;
214 }
215 $this->handle_authorize_page();
216 return;
217 }
218
219 if ( empty( $wp->query_vars[ self::QUERY_VAR ] ) ) {
220 return;
221 }
222
223 $request = new \WP_REST_Request( 'POST', '/' . self::NS . '/mcp' );
224 $request->set_header( 'content-type', 'application/json' );
225 // Carry the auth header + raw body from the live PHP request.
226 $auth = self::server_header( 'authorization' );
227 if ( null !== $auth ) {
228 $request->set_header( 'authorization', $auth );
229 }
230 // Token embedded in the URL path (/thinkrank/mcp/<token>) — surface it
231 // as a Bearer header so Mcp_Server validates it the same way. A real
232 // Authorization header (if also sent) takes precedence.
233 $path_token = isset( $wp->query_vars[ self::TOKEN_QUERY_VAR ] )
234 ? (string) $wp->query_vars[ self::TOKEN_QUERY_VAR ]
235 : '';
236 if ( '' !== $path_token && '' === (string) $request->get_header( 'authorization' ) ) {
237 $request->set_header( 'authorization', 'Bearer ' . $path_token );
238 }
239 $request->set_body( (string) file_get_contents( 'php://input' ) );
240
241 $response = Mcp_Server::handle( $request );
242 $this->emit_json( $response );
243 }
244
245 // -- REST registration --
246
247 /**
248 * Register the REST routes: the MCP JSON-RPC fallback, the admin
249 * management routes, and the OAuth registration/token endpoints.
250 *
251 * @return void
252 */
253 public function register_rest(): void {
254 // --- MCP JSON-RPC endpoint (fallback path via wp-json) -----------
255 // permission_callback is __return_true because Mcp_Server does its own
256 // token auth and must reply with a JSON-RPC 401 + WWW-Authenticate,
257 // not a bare WP permission failure.
258 register_rest_route(
259 self::NS,
260 '/mcp',
261 [
262 'methods' => 'POST',
263 'callback' => [ $this, 'rest_mcp' ],
264 'permission_callback' => '__return_true',
265 ]
266 );
267
268 // --- Admin-only management routes (the MCP page) ------------------
269 register_rest_route(
270 self::NS,
271 '/mcp/connection',
272 [
273 'methods' => 'GET',
274 'callback' => [ $this, 'rest_connection' ],
275 'permission_callback' => [ $this, 'admin_permission' ],
276 ]
277 );
278 register_rest_route(
279 self::NS,
280 '/mcp/connect',
281 [
282 'methods' => 'POST',
283 'callback' => [ $this, 'rest_connect' ],
284 'permission_callback' => [ $this, 'admin_permission' ],
285 'args' => [
286 'read_only' => [
287 'type' => 'boolean',
288 'required' => false,
289 'default' => false,
290 'description' => 'Grant read-only access (no SEO metadata or settings changes).',
291 ],
292 ],
293 ]
294 );
295 register_rest_route(
296 self::NS,
297 '/mcp/rotate',
298 [
299 'methods' => 'POST',
300 'callback' => [ $this, 'rest_rotate' ],
301 'permission_callback' => [ $this, 'admin_permission' ],
302 'args' => [
303 'read_only' => [
304 'type' => 'boolean',
305 'required' => false,
306 'description' => 'Optionally set read-only on the new token; omit to keep current scopes.',
307 ],
308 ],
309 ]
310 );
311 register_rest_route(
312 self::NS,
313 '/mcp/disconnect',
314 [
315 'methods' => 'POST',
316 'callback' => [ $this, 'rest_disconnect' ],
317 'permission_callback' => [ $this, 'admin_permission' ],
318 ]
319 );
320
321 // Live round-trip diagnostic for the MCP page (see #189). Admin-only;
322 // exercises the endpoint the way an external client would.
323 register_rest_route(
324 self::NS,
325 '/mcp/self-test',
326 [
327 'methods' => 'POST',
328 'callback' => [ $this, 'rest_self_test' ],
329 'permission_callback' => [ $this, 'admin_permission' ],
330 ]
331 );
332
333 // --- OAuth 2.1 authorization server (the "paste a URL only" path) -
334 // Discovery, dynamic client registration, and the token endpoint are
335 // all public (permission enforced inside): a client must reach them
336 // BEFORE it holds any credential.
337 register_rest_route(
338 self::NS,
339 '/mcp/oauth/register',
340 [
341 'methods' => 'POST',
342 'callback' => [ $this, 'rest_oauth_register' ],
343 'permission_callback' => '__return_true',
344 ]
345 );
346 // NOTE: /authorize is deliberately NOT a REST route — it is served as
347 // a normal front-end page at /thinkrank/authorize (see
348 // handle_authorize_page) so cookie auth works after wp-login.
349 register_rest_route(
350 self::NS,
351 '/mcp/oauth/token',
352 [
353 'methods' => 'POST',
354 'callback' => [ $this, 'rest_oauth_token' ],
355 'permission_callback' => '__return_true',
356 ]
357 );
358 }
359
360 /**
361 * Capability gate for the admin-only management routes.
362 *
363 * @return bool
364 */
365 public function admin_permission(): bool {
366 return current_user_can( 'manage_options' );
367 }
368
369 // -- Handlers ----------------------------------------------------------
370
371 /**
372 * MCP JSON-RPC over the wp-json fallback path.
373 *
374 * @param \WP_REST_Request $request Incoming request.
375 * @return \WP_REST_Response
376 */
377 public function rest_mcp( \WP_REST_Request $request ): \WP_REST_Response {
378 $response = Mcp_Server::handle( $request );
379 // Advertise the MCP protocol version on the wp-json transport too, so
380 // both endpoints behave identically to a strict Streamable-HTTP client.
381 $response->header( 'MCP-Protocol-Version', Mcp_Server::PROTOCOL_VERSION );
382 return $response;
383 }
384
385 /**
386 * GET /mcp/connection — pairing status for the MCP page.
387 *
388 * @return \WP_REST_Response
389 */
390 public function rest_connection(): \WP_REST_Response {
391 return rest_ensure_response( Mcp_Pairing::public_status() );
392 }
393
394 /**
395 * POST /mcp/connect — mint a connection token.
396 *
397 * @param \WP_REST_Request $request Carries optional read_only.
398 * @return \WP_REST_Response
399 */
400 public function rest_connect( \WP_REST_Request $request ): \WP_REST_Response {
401 $read_only = (bool) $request->get_param( 'read_only' );
402 return rest_ensure_response( Mcp_Pairing::connect( $read_only ) );
403 }
404
405 /**
406 * POST /mcp/rotate — mint a fresh token, invalidating the old one.
407 *
408 * @param \WP_REST_Request $request Carries optional read_only.
409 * @return \WP_REST_Response
410 */
411 public function rest_rotate( \WP_REST_Request $request ): \WP_REST_Response {
412 $read_only = null;
413 if ( null !== $request->get_param( 'read_only' ) ) {
414 $read_only = (bool) $request->get_param( 'read_only' );
415 }
416 return rest_ensure_response( Mcp_Pairing::rotate( $read_only ) );
417 }
418
419 /**
420 * POST /mcp/disconnect — revoke the connection token + all OAuth grants.
421 *
422 * @return \WP_REST_Response
423 */
424 public function rest_disconnect(): \WP_REST_Response {
425 return rest_ensure_response( Mcp_Pairing::disconnect() );
426 }
427
428 /**
429 * POST /mcp/self-test — run the live round-trip diagnostic (see #189).
430 *
431 * @return \WP_REST_Response
432 */
433 public function rest_self_test(): \WP_REST_Response {
434 return rest_ensure_response( Mcp_Self_Test::run() );
435 }
436
437 // -- OAuth 2.1 handlers ------------------------------------------------
438
439 /**
440 * POST /mcp/oauth/register — RFC 7591 dynamic client registration.
441 *
442 * @param \WP_REST_Request $request JSON body with redirect_uris.
443 * @return \WP_REST_Response|\WP_Error
444 */
445 public function rest_oauth_register( \WP_REST_Request $request ) {
446 if ( ! self::is_enabled() ) {
447 return new \WP_Error( 'thinkrank_mcp_disabled', __( 'MCP is disabled on this site.', 'thinkrank' ), [ 'status' => 403 ] );
448 }
449 $body = $request->get_json_params();
450 if ( ! is_array( $body ) ) {
451 $body = [];
452 }
453 $result = Mcp_OAuth::register_client( $body );
454 if ( is_wp_error( $result ) ) {
455 return $result;
456 }
457 return new \WP_REST_Response( $result, 201 );
458 }
459
460 /**
461 * POST /mcp/oauth/token — exchange a code (or refresh token) for tokens.
462 *
463 * @param \WP_REST_Request $request Form-encoded or JSON token request.
464 * @return \WP_REST_Response
465 */
466 public function rest_oauth_token( \WP_REST_Request $request ): \WP_REST_Response {
467 if ( ! self::is_enabled() ) {
468 $response = new \WP_REST_Response(
469 [
470 'error' => 'invalid_request',
471 'error_description' => 'MCP is disabled on this site.',
472 ],
473 403
474 );
475 $response->header( 'Cache-Control', 'no-store' );
476 return $response;
477 }
478
479 // Token requests are application/x-www-form-urlencoded per OAuth, but
480 // accept JSON too. get_body_params() covers the form case.
481 $body = $request->get_body_params();
482 if ( empty( $body ) ) {
483 $json = $request->get_json_params();
484 $body = is_array( $json ) ? $json : [];
485 }
486 $body = array_map( 'strval', $body );
487
488 $result = Mcp_OAuth::exchange_token( $body );
489 if ( is_wp_error( $result ) ) {
490 $data = $result->get_error_data();
491 $response = new \WP_REST_Response(
492 [
493 'error' => isset( $data['error'] ) ? $data['error'] : 'invalid_request',
494 'error_description' => isset( $data['error_description'] ) ? $data['error_description'] : $result->get_error_message(),
495 ],
496 isset( $data['status'] ) ? (int) $data['status'] : 400
497 );
498 $response->header( 'Cache-Control', 'no-store' );
499 return $response;
500 }
501 $response = new \WP_REST_Response( $result, 200 );
502 $response->header( 'Cache-Control', 'no-store' );
503 $response->header( 'Pragma', 'no-cache' );
504 return $response;
505 }
506
507 // -- OAuth authorize page ------------------------------------------------
508
509 /**
510 * The browser-facing OAuth authorize page (served at /thinkrank/authorize
511 * via a rewrite, NOT the REST API). Reads request params from the
512 * superglobals because this is a normal front-end request where cookie
513 * auth populates is_user_logged_in().
514 *
515 * GET renders the consent screen (requires a logged-in admin; anonymous
516 * users go to wp-login and return here). POST is the nonce-checked consent
517 * submission: Approve issues a code and 302s to the client's redirect_uri;
518 * Deny 302s back with error=access_denied. Always emits its own response
519 * (HTML page or redirect) and exits.
520 *
521 * @return void
522 */
523 public function handle_authorize_page(): void {
524 $is_post = isset( $_SERVER['REQUEST_METHOD'] ) && 'POST' === strtoupper( (string) wp_unslash( $_SERVER['REQUEST_METHOD'] ) );
525 // Params come from GET on the consent link and POST on the form submit.
526 // Nonce is verified below before any POST value is acted on.
527 // phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing
528 $source = $is_post ? $_POST : $_GET;
529 // phpcs:enable
530 $params = [];
531 foreach ( [ 'client_id', 'redirect_uri', 'response_type', 'code_challenge', 'code_challenge_method', 'scope', 'state', 'approve', 'deny', '_thinkrank_oauth_nonce' ] as $k ) {
532 $params[ $k ] = isset( $source[ $k ] ) ? sanitize_text_field( wp_unslash( $source[ $k ] ) ) : '';
533 }
534
535 // Validate the OAuth params before touching the session.
536 $req = Mcp_OAuth::validate_authorize_request( $params );
537 if ( is_wp_error( $req ) ) {
538 $data = $req->get_error_data();
539 $redirectable = is_array( $data ) && ! empty( $data['redirectable'] );
540 // Only redirect the error back when redirect_uri is verified valid;
541 // otherwise show a page (never bounce to an unverified URL).
542 if ( $redirectable && '' !== $params['redirect_uri'] ) {
543 $this->redirect_error( $params['redirect_uri'], $req->get_error_code(), $req->get_error_message(), $params['state'] );
544 }
545 $this->emit_oauth_error_page( $req->get_error_message() );
546 }
547
548 // Require a logged-in admin. Anonymous → wp-login, back to this URL.
549 if ( ! is_user_logged_in() ) {
550 $this->redirect_to_login();
551 }
552 if ( ! current_user_can( 'manage_options' ) ) {
553 $this->emit_oauth_error_page(
554 __( 'You must be an administrator to authorize an AI assistant to manage SEO on this site.', 'thinkrank' )
555 );
556 }
557
558 // POST = consent form submitted.
559 if ( $is_post ) {
560 if ( ! wp_verify_nonce( $params['_thinkrank_oauth_nonce'], 'thinkrank_oauth_consent' ) ) {
561 $this->emit_oauth_error_page( __( 'Security check failed. Please try connecting again.', 'thinkrank' ) );
562 }
563 if ( '' === $params['approve'] ) {
564 $this->redirect_error( $req['redirect_uri'], 'access_denied', 'The user denied the request.', $req['state'] );
565 }
566 $code = Mcp_OAuth::issue_code( $req, get_current_user_id() );
567 $this->redirect_success( $req['redirect_uri'], $code, $req['state'] );
568 }
569
570 // GET = render the consent screen.
571 $this->emit_consent_screen( $req );
572 }
573
574 // -- OAuth browser-response helpers ------------------------------------
575
576 /**
577 * The absolute URL of the current authorize request (for login return).
578 *
579 * @return string
580 */
581 private function current_authorize_url(): string {
582 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- reconstructing the current URL for a login round-trip; escaped at use.
583 $uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '';
584 return home_url( $uri );
585 }
586
587 /**
588 * Send an anonymous visitor to wp-login, returning to this authorize URL.
589 *
590 * @return void
591 */
592 private function redirect_to_login(): void {
593 wp_safe_redirect( wp_login_url( $this->current_authorize_url() ) );
594 exit;
595 }
596
597 /**
598 * 302 back to the client with the authorization code (+ state).
599 *
600 * @param string $redirect_uri Validated client redirect URI.
601 * @param string $code Authorization code.
602 * @param string $state Client state.
603 * @return void
604 */
605 private function redirect_success( string $redirect_uri, string $code, string $state ): void {
606 $args = [ 'code' => $code ];
607 if ( '' !== $state ) {
608 $args['state'] = $state;
609 }
610 // Not wp_safe_redirect: redirect_uri is a client-registered off-site
611 // callback, already validated against the client's registered set.
612 wp_redirect( add_query_arg( $args, $redirect_uri ) ); // phpcs:ignore WordPress.Security.SafeRedirect -- validated OAuth redirect_uri.
613 exit;
614 }
615
616 /**
617 * 302 back to the client with an OAuth error (+ state).
618 *
619 * @param string $redirect_uri Validated client redirect URI.
620 * @param string $error OAuth error code.
621 * @param string $description Human-readable description.
622 * @param string $state Client state.
623 * @return void
624 */
625 private function redirect_error( string $redirect_uri, string $error, string $description, string $state ): void {
626 $args = [
627 'error' => $error,
628 'error_description' => $description,
629 ];
630 if ( '' !== $state ) {
631 $args['state'] = $state;
632 }
633 wp_redirect( add_query_arg( array_map( 'rawurlencode', $args ), $redirect_uri ) ); // phpcs:ignore WordPress.Security.SafeRedirect -- validated OAuth redirect_uri.
634 exit;
635 }
636
637 /**
638 * Render the consent screen. Minimal self-contained HTML (no admin
639 * chrome — this is a client-facing OAuth page). Approve/Deny post back
640 * to the same authorize URL with a nonce.
641 *
642 * @param array<string,string> $req Validated authorize params.
643 * @return void
644 */
645 private function emit_consent_screen( array $req ): void {
646 $read_only = Mcp_OAuth::scope_is_read_only( $req['scope'] );
647 $access_label = $read_only ? __( 'Read-only', 'thinkrank' ) : __( 'Read & write', 'thinkrank' );
648 $access_desc = $read_only
649 ? __( 'Review your SEO across posts and site settings — metadata, schema, sitemaps, robots, social, and SEO scores. No changes are made.', 'thinkrank' )
650 : __( 'Read and improve your SEO across posts and site settings — metadata, schema, sitemaps, robots, social, indexing, and SEO scores.', 'thinkrank' );
651 $client = '' !== $req['client_name'] ? $req['client_name'] : __( 'An AI assistant', 'thinkrank' );
652 $action_url = Mcp_OAuth::authorize_url();
653 $nonce = wp_create_nonce( 'thinkrank_oauth_consent' );
654 $user = wp_get_current_user();
655
656 // Preserve every OAuth param so the POST re-validates identically.
657 $hidden = '';
658 foreach ( [ 'client_id', 'redirect_uri', 'code_challenge', 'scope', 'state' ] as $k ) {
659 $val = 'scope' === $k ? $req['scope'] : ( $req[ $k ] ?? '' );
660 $hidden .= sprintf( '<input type="hidden" name="%s" value="%s" />', esc_attr( $k ), esc_attr( (string) $val ) );
661 }
662 // code_challenge_method + response_type are re-asserted for validation.
663 $hidden .= '<input type="hidden" name="code_challenge_method" value="S256" />';
664 $hidden .= '<input type="hidden" name="response_type" value="code" />';
665
666 status_header( 200 );
667 header( 'Content-Type: text/html; charset=utf-8' );
668 header( 'Cache-Control: no-store' );
669
670 $host = (string) wp_parse_url( home_url(), PHP_URL_HOST );
671 $logo = '<svg width="36" height="36" viewBox="0 0 29 29" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">'
672 . '<g clip-path="url(#tr_clip0)">'
673 . '<path d="M0.436523 6.61665C0.436523 3.15762 3.24063 0.353516 6.69966 0.353516H22.1734C25.6324 0.353516 28.4365 3.15762 28.4365 6.61665V22.0904C28.4365 25.5494 25.6324 28.3535 22.1734 28.3535H6.69966C3.24063 28.3535 0.436523 25.5494 0.436523 22.0904V6.61665Z" fill="url(#tr_p0)"/>'
674 . '<path d="M29.1618 8.11914C29.1618 8.11914 29.1622 8.11903 29.3906 8.71865C29.6058 9.28347 29.6182 9.31627 29.6189 9.31817C29.6189 9.31817 29.6185 9.3185 29.6182 9.31862C29.6176 9.31886 29.6166 9.31924 29.6153 9.31976C29.6124 9.32086 29.6078 9.32246 29.6018 9.32477C29.5898 9.32942 29.5714 9.33652 29.5471 9.34596C29.4986 9.36487 29.4265 9.39344 29.3332 9.43073C29.1465 9.50532 28.8754 9.61538 28.5404 9.75703C27.8699 10.0405 26.9452 10.449 25.9291 10.9483C23.8797 11.9554 21.5331 13.2968 20.1165 14.6951C19.2976 15.5641 18.5831 16.4248 17.898 17.2522C17.2153 18.0766 16.555 18.8765 15.8671 19.588C14.477 21.0257 12.9316 22.1479 10.6959 22.53C9.60585 22.7163 8.53683 22.6112 7.52835 22.4516C6.48364 22.2863 5.5662 22.0772 4.59619 21.989C3.82718 21.9191 3.16045 21.9788 2.48024 22.211C1.79208 22.4458 1.05578 22.8689 0.16582 23.5725L-0.629883 22.5658C0.329559 21.8073 1.19422 21.2939 2.06576 20.9965C2.94522 20.6963 3.79701 20.6277 4.7124 20.7109C5.73306 20.8037 6.79732 21.0365 7.7291 21.184C8.69711 21.3372 9.59965 21.4156 10.4799 21.2651C12.3522 20.9451 13.6648 20.0196 14.9444 18.6962C15.5914 18.027 16.2189 17.2678 16.9095 16.4337C17.5953 15.6055 18.3371 14.7115 19.1918 13.8053L19.1994 13.7973L19.2073 13.7893C20.7824 12.2312 23.2976 10.8117 25.3631 9.79668C26.4061 9.28415 27.3538 8.86556 28.0407 8.5751C28.3843 8.4298 28.6633 8.31639 28.8569 8.239C28.9537 8.20031 29.0292 8.17052 29.0809 8.15036C29.1068 8.14028 29.1268 8.13259 29.1404 8.12734C29.1472 8.12472 29.1525 8.12281 29.1561 8.12142C29.1579 8.12073 29.1592 8.11998 29.1602 8.1196C29.1607 8.11941 29.1613 8.11925 29.1616 8.11914H29.1618Z" fill="url(#tr_p2)"/>'
675 . '<path d="M22.3437 13.4975C22.3437 14.5285 21.508 15.3642 20.477 15.3642C19.4461 15.3642 18.6104 14.5285 18.6104 13.4975C18.6104 12.4666 19.4461 11.6309 20.477 11.6309C21.508 11.6309 22.3437 12.4666 22.3437 13.4975Z" fill="url(#tr_p3)"/>'
676 . '<path d="M20.4766 11.2734C21.7049 11.2734 22.7009 12.2688 22.7012 13.4971C22.7012 14.7256 21.7051 15.7217 20.4766 15.7217C19.2482 15.7214 18.2529 14.7254 18.2529 13.4971C18.2532 12.2689 19.2484 11.2737 20.4766 11.2734Z" stroke="white" stroke-width="0.715555"/>'
677 . '<path d="M15.6201 6.78776C15.9391 6.88104 15.9391 7.33291 15.6201 7.42619L13.5873 8.02069C13.4784 8.05254 13.3933 8.13768 13.3614 8.24656L12.7669 10.2794C12.6736 10.5984 12.2218 10.5984 12.1285 10.2794L11.534 8.24656C11.5022 8.13768 11.417 8.05254 11.3081 8.02069L9.27529 7.42619C8.95631 7.33291 8.9563 6.88104 9.27528 6.78776L11.3081 6.19326C11.417 6.16141 11.5022 6.07627 11.534 5.96739L12.1285 3.93455C12.2218 3.61557 12.6736 3.61557 12.7669 3.93455L13.3614 5.96739C13.3933 6.07627 13.4784 6.16141 13.5873 6.19326L15.6201 6.78776Z" fill="url(#tr_p4)"/>'
678 . '<path d="M11.6747 13.8907C11.8298 13.9361 11.8298 14.1557 11.6747 14.201L10.4245 14.5667C10.3716 14.5822 10.3302 14.6235 10.3147 14.6765L9.94909 15.9267C9.90375 16.0817 9.68413 16.0817 9.63879 15.9267L9.27317 14.6765C9.25769 14.6235 9.21631 14.5822 9.16339 14.5667L7.91315 14.201C7.75811 14.1557 7.75811 13.9361 7.91315 13.8907L9.16339 13.5251C9.21631 13.5096 9.25769 13.4683 9.27317 13.4153L9.63879 12.1651C9.68413 12.0101 9.90375 12.0101 9.94909 12.1651L10.3147 13.4153C10.3302 13.4683 10.3716 13.5096 10.4245 13.5251L11.6747 13.8907Z" fill="url(#tr_p5)"/>'
679 . '</g>'
680 . '<rect x="0.353516" y="0.353516" width="28" height="28" rx="7.76216" stroke="#B6CBFF" stroke-width="0.707321"/>'
681 . '<defs>'
682 . '<linearGradient id="tr_p0" x1="14.4365" y1="0.353516" x2="14.4365" y2="28.3535" gradientUnits="userSpaceOnUse"><stop stop-color="#5A57FF"/><stop offset="1" stop-color="#5B98FF"/></linearGradient>'
683 . '<linearGradient id="tr_p2" x1="29.2845" y1="8.77232" x2="0.650625" y2="22.9504" gradientUnits="userSpaceOnUse"><stop stop-color="#BDC1FF"/><stop offset="0.420828" stop-color="#D6E8FF"/><stop offset="1" stop-color="#7391FE"/></linearGradient>'
684 . '<radialGradient id="tr_p3" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(22.1572 11.6007) rotate(134.421) scale(4.39003)"><stop offset="0.321315" stop-color="#BAFEFF"/><stop offset="1" stop-color="#011FFF"/></radialGradient>'
685 . '<linearGradient id="tr_p4" x1="9.87102" y1="1.28287" x2="14.9766" y2="13.0695" gradientUnits="userSpaceOnUse"><stop stop-color="#FFE6FC"/><stop offset="0.350962" stop-color="#FFE6FC"/><stop offset="1" stop-color="#AA00F2"/></linearGradient>'
686 . '<linearGradient id="tr_p5" x1="8.28563" y1="10.6367" x2="11.2743" y2="17.5362" gradientUnits="userSpaceOnUse"><stop stop-color="#FFE6FC"/><stop offset="0.350962" stop-color="#FFE6FC"/><stop offset="1" stop-color="#AA00F2"/></linearGradient>'
687 . '<clipPath id="tr_clip0"><rect x="0.353516" y="0.353516" width="28" height="28" rx="7.76216" fill="white"/></clipPath>'
688 . '</defs></svg>';
689 $lock = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>';
690
691 echo '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>' . esc_html__( 'Authorize AI access', 'thinkrank' ) . '</title>';
692 echo '<style>'
693 . ':root{color-scheme:dark}*{box-sizing:border-box}'
694 . 'body{font:15px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:radial-gradient(1100px 600px at 50% -15%,#20284a,#0b1020 62%);color:#e7ecf3;margin:0;display:flex;min-height:100vh;align-items:center;justify-content:center;padding:24px}'
695 . '.card{width:100%;max-width:460px;background:#141a2e;border:1px solid #263149;border-radius:20px;padding:28px;box-shadow:0 24px 60px rgba(0,0,0,.5)}'
696 . '.brand{display:flex;align-items:center;gap:10px;margin-bottom:22px}'
697 . '.logo{width:36px;height:36px;border-radius:11px;display:flex;align-items:center;justify-content:center;box-shadow:0 6px 16px rgba(99,102,241,.4)}'
698 . '.brand b{font-size:14px;font-weight:700;letter-spacing:.02em}'
699 . 'h1{font-size:20px;font-weight:700;margin:0 0 6px}'
700 . '.sub{color:#9aa6be;font-size:13.5px;margin:0 0 22px}.sub strong{color:#e7ecf3;font-weight:600}'
701 . '.rows{border:1px solid #263149;border-radius:14px;overflow:hidden;margin-bottom:16px}'
702 . '.row{display:flex;justify-content:space-between;align-items:center;gap:16px;padding:13px 16px;font-size:13.5px}'
703 . '.row+.row,.access{border-top:1px solid #263149}'
704 . '.row .k{color:#9aa6be}.row .v{font-weight:600;text-align:right;word-break:break-word}'
705 . '.access{padding:14px 16px;background:rgba(99,102,241,.07)}'
706 . '.access .k{color:#9aa6be;font-size:13px;margin-bottom:8px}'
707 . '.badge{display:inline-flex;align-items:center;font-size:12px;font-weight:700;padding:3px 10px;border-radius:999px;background:rgba(99,102,241,.18);color:#c7cbff;border:1px solid rgba(99,102,241,.4)}'
708 . '.badge.ro{background:rgba(245,158,11,.15);color:#fcd9a1;border-color:rgba(245,158,11,.4)}'
709 . '.access .d{color:#c3ccdd;font-size:13px;margin-top:8px}'
710 . '.note{display:flex;align-items:center;gap:7px;color:#7f8aa3;font-size:12px;margin:0 0 20px}'
711 . '.actions{display:flex;gap:12px}'
712 . 'button{flex:1;padding:13px;border-radius:12px;border:0;font-size:14px;font-weight:600;cursor:pointer;transition:filter .15s,transform .05s}button:active{transform:translateY(1px)}'
713 . '.approve{background:linear-gradient(135deg,#6366f1,#7c73ff);color:#fff;box-shadow:0 8px 20px rgba(99,102,241,.38)}.approve:hover{filter:brightness(1.07)}'
714 . '.deny{background:transparent;color:#aeb8cc;border:1px solid #33405c}.deny:hover{background:rgba(255,255,255,.04)}'
715 . '</style></head><body><div class="card">';
716
717 echo '<div class="brand"><span class="logo">' . $logo . '</span><b>ThinkRank</b></div>'; // phpcs:ignore WordPress.Security.EscapeOutput -- static markup.
718 echo '<h1>' . esc_html__( 'Connect to ThinkRank', 'thinkrank' ) . '</h1>';
719 $sub = sprintf(
720 /* translators: %s: AI client name, already escaped and wrapped in <strong>. */
721 esc_html__( '%s wants to manage SEO on this site.', 'thinkrank' ),
722 '<strong>' . esc_html( $client ) . '</strong>'
723 );
724 echo '<p class="sub">' . $sub . '</p>'; // phpcs:ignore WordPress.Security.EscapeOutput -- static translation; client name esc_html'd.
725
726 echo '<div class="rows">';
727 echo '<div class="row"><span class="k">' . esc_html__( 'Site', 'thinkrank' ) . '</span><span class="v">' . esc_html( $host ) . '</span></div>';
728 echo '<div class="row"><span class="k">' . esc_html__( 'Signed in as', 'thinkrank' ) . '</span><span class="v">' . esc_html( $user->user_login ) . '</span></div>';
729 echo '<div class="access"><div class="k">' . esc_html__( 'Access', 'thinkrank' ) . '</div>';
730 echo '<span class="badge ' . ( $read_only ? 'ro' : '' ) . '">' . esc_html( $access_label ) . '</span>';
731 echo '<div class="d">' . esc_html( $access_desc ) . '</div></div>';
732 echo '</div>';
733
734 echo '<p class="note">' . $lock . '<span>' . esc_html__( 'Secured with OAuth. Revoke anytime in ThinkRank → MCP.', 'thinkrank' ) . '</span></p>'; // phpcs:ignore WordPress.Security.EscapeOutput -- static icon; text esc_html'd.
735
736 echo '<form method="post" action="' . esc_url( $action_url ) . '">';
737 echo $hidden; // phpcs:ignore WordPress.Security.EscapeOutput -- built from esc_attr() above.
738 echo '<input type="hidden" name="_thinkrank_oauth_nonce" value="' . esc_attr( $nonce ) . '" />';
739 echo '<div class="actions">';
740 echo '<button class="deny" name="deny" value="1">' . esc_html__( 'Deny', 'thinkrank' ) . '</button>';
741 echo '<button class="approve" name="approve" value="1">' . esc_html__( 'Approve', 'thinkrank' ) . '</button>';
742 echo '</div></form></div></body></html>';
743 exit;
744 }
745
746 /**
747 * Render a standalone OAuth error page (no redirect).
748 *
749 * @param string $message Error message.
750 * @return void
751 */
752 private function emit_oauth_error_page( string $message ): void {
753 status_header( 400 );
754 header( 'Content-Type: text/html; charset=utf-8' );
755 header( 'Cache-Control: no-store' );
756 echo '<!doctype html><html><head><meta charset="utf-8"><title>' . esc_html__( 'Authorization error', 'thinkrank' ) . '</title>';
757 echo '<style>body{font:15px/1.5 -apple-system,sans-serif;background:#0f172a;color:#e2e8f0;display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0}'
758 . '.card{background:#1e293b;border:1px solid #334155;border-radius:16px;max-width:440px;padding:32px;text-align:center}</style></head><body>';
759 echo '<div class="card"><h1>' . esc_html__( 'Could not authorize', 'thinkrank' ) . '</h1><p>' . esc_html( $message ) . '</p></div></body></html>';
760 exit;
761 }
762
763 // -- Helpers --
764
765 /**
766 * Read an inbound HTTP header from $_SERVER (for the pretty path).
767 *
768 * @param string $name Header name.
769 * @return string|null
770 */
771 private static function server_header( string $name ): ?string {
772 $key = 'HTTP_' . strtoupper( str_replace( '-', '_', $name ) );
773 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- token compared constant-time downstream; raw header needed verbatim.
774 return isset( $_SERVER[ $key ] ) ? wp_unslash( $_SERVER[ $key ] ) : null;
775 }
776
777 /**
778 * Emit a WP_REST_Response as a JSON HTTP response and stop.
779 *
780 * @param \WP_REST_Response $response Response to emit.
781 * @return void
782 */
783 private function emit_json( \WP_REST_Response $response ): void {
784 status_header( $response->get_status() );
785 // MCP Streamable HTTP: advertise the protocol version we speak so a
786 // strict client can pin it. We answer JSON (a spec-permitted response
787 // type); we never open an SSE stream, so no session header is needed.
788 header( 'MCP-Protocol-Version: ' . Mcp_Server::PROTOCOL_VERSION );
789 // Forward any headers the handler set (notably WWW-Authenticate on a
790 // 401, which drives the OAuth discovery flow).
791 foreach ( $response->get_headers() as $name => $value ) {
792 header( $name . ': ' . $value );
793 }
794 $data = $response->get_data();
795 if ( null !== $data ) {
796 header( 'Content-Type: application/json; charset=utf-8' );
797 echo wp_json_encode( $data );
798 }
799 exit;
800 }
801 }
802