PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.7.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.7.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 2.7.0, at includes/mcp/class-mcp-manager.php

1,108 lines 48.0 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 // Published /.well-known/ files are served ahead of WordPress, so a
86 // site URL change leaves them advertising the old domain's issuer with
87 // nothing to correct them. Registered unconditionally: a stale
88 // document is harmful whether or not MCP is currently enabled (#486).
89 Mcp_Static_Discovery::init();
90
91 // Pretty per-site endpoint: /thinkrank/mcp → MCP JSON-RPC handler.
92 add_action( 'init', [ $this, 'add_rewrite' ] );
93 add_filter( 'query_vars', [ $this, 'register_query_var' ] );
94 add_action( 'parse_request', [ $this, 'maybe_handle_pretty_endpoint' ] );
95
96 // The one broken state the server can't see from inside a request:
97 // MCP enabled but the bundled Abilities runtime absent. Everything
98 // else still works — OAuth discovers, tokens mint, clients connect —
99 // and tools/list is an empty array served as success. Three layers
100 // each "no-op gracefully" (the is_readable() require, the registrar,
101 // the tool registry) and composed they manufacture a connector that
102 // connects and offers nothing, with no signal anywhere. Say it loudly
103 // where an admin will look.
104 add_action( 'admin_notices', [ $this, 'warn_when_runtime_missing' ] );
105 }
106
107 /**
108 * Admin notice when MCP is enabled but the Abilities runtime is missing.
109 *
110 * That combination almost always means an incomplete package — a source
111 * archive or a zip built without `dependencies/vendor/` (the bundled
112 * Abilities API + MCP adapter). Shown to admins on every screen: the fix
113 * is reinstalling the plugin, and a user mid-support-ticket needs to see
114 * it without knowing which screen to visit.
115 *
116 * @return void
117 */
118 public function warn_when_runtime_missing(): void {
119 if ( ! self::is_enabled() || function_exists( 'wp_register_ability' ) ) {
120 return;
121 }
122 if ( ! current_user_can( 'manage_options' ) ) {
123 return;
124 }
125 printf(
126 '<div class="notice notice-error"><p><strong>%s</strong> %s</p></div>',
127 esc_html__( 'ThinkRank MCP: AI assistants will connect but see no tools.', 'thinkrank' ),
128 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' )
129 );
130 }
131
132 /**
133 * Whether the MCP integration is enabled via the admin setting.
134 *
135 * @return bool
136 */
137 public static function is_enabled(): bool {
138 return (bool) Settings::instance()->get( 'enable_mcp', false );
139 }
140
141 // -- Pretty endpoint: /thinkrank/mcp --
142
143 /**
144 * Register rewrite rules for the MCP endpoint, OAuth discovery documents,
145 * and the browser-facing authorize page.
146 *
147 * @return void
148 */
149 public function add_rewrite(): void {
150 // Token-in-URL form: /thinkrank/mcp/<token> — a single string the user
151 // pastes into their AI client (no separate token field). The bare
152 // /thinkrank/mcp still works with a Bearer token.
153 add_rewrite_rule(
154 '^thinkrank/mcp/([a-f0-9]{64})/?$',
155 'index.php?' . self::QUERY_VAR . '=1&' . self::TOKEN_QUERY_VAR . '=$matches[1]',
156 'top'
157 );
158 add_rewrite_rule( '^thinkrank/mcp/?$', 'index.php?' . self::QUERY_VAR . '=1', 'top' );
159
160 // OAuth discovery documents. RFC 9728 §3.1 / RFC 8414 §3.1 place the
161 // `.well-known` segment BEFORE the resource path, so our resource at
162 // /thinkrank/mcp is discovered at the path-suffixed form:
163 // /.well-known/oauth-protected-resource/thinkrank/mcp
164 // /.well-known/oauth-authorization-server/thinkrank/mcp
165 // The OAuth issuer is the path-based identifier home_url('/thinkrank/mcp')
166 // (see Mcp_OAuth::issuer), so spec-compliant clients derive exactly
167 // these URLs — and the rule stays specific to OUR path. That matters
168 // for coexistence: another plugin serving its own MCP OAuth surface
169 // (e.g. xSpeed) claims the generic `(?:/.*)?` root rule, and rewrite
170 // rules are keyed by regex, so a shared broad rule would be silently
171 // overwritten by whichever plugin registers last.
172 add_rewrite_rule(
173 '^\.well-known/oauth-(protected-resource|authorization-server)/thinkrank/mcp/?$',
174 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]',
175 'top'
176 );
177 // Root-form fallback for clients that only try the bare well-known
178 // URL. Harmless when another plugin also registers this exact regex —
179 // last registrant wins, and our clients use the path-suffixed form.
180 add_rewrite_rule(
181 '^\.well-known/oauth-(protected-resource|authorization-server)(?:/.*)?/?$',
182 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]',
183 'top'
184 );
185 // Suffix form: <issuer>/.well-known/... . RFC 8414 specifies the
186 // path-INSERT form above, but the older OpenID Connect Discovery
187 // convention appends instead, and clients built on an OIDC library
188 // try that shape first (sometimes only that shape). Serving both
189 // costs two rules and removes a whole class of "server does not
190 // implement OAuth" failures from clients that never fall back.
191 add_rewrite_rule(
192 '^thinkrank/mcp/\.well-known/oauth-(protected-resource|authorization-server)/?$',
193 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]',
194 'top'
195 );
196 add_rewrite_rule(
197 '^thinkrank/mcp/\.well-known/openid-configuration/?$',
198 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=authorization-server',
199 'top'
200 );
201
202 // Browser-facing OAuth consent page — served OUTSIDE REST so cookie
203 // auth (is_user_logged_in) works after the wp-login round-trip.
204 add_rewrite_rule( '^thinkrank/authorize/?$', 'index.php?' . self::AUTHORIZE_QUERY_VAR . '=1', 'top' );
205
206 // Self-heal: flush once if ANY of our rules is missing from the stored
207 // rewrite table, so the endpoints work without a manual permalink
208 // re-save (and newly added rules trigger a re-flush on upgrade).
209 $expected = [
210 '^thinkrank/mcp/([a-f0-9]{64})/?$',
211 '^thinkrank/mcp/?$',
212 '^\.well-known/oauth-(protected-resource|authorization-server)/thinkrank/mcp/?$',
213 '^thinkrank/mcp/\.well-known/oauth-(protected-resource|authorization-server)/?$',
214 '^thinkrank/mcp/\.well-known/openid-configuration/?$',
215 '^thinkrank/authorize/?$',
216 ];
217 $rules = get_option( 'rewrite_rules' );
218 if ( is_array( $rules ) ) {
219 foreach ( $expected as $rule ) {
220 if ( ! isset( $rules[ $rule ] ) ) {
221 flush_rewrite_rules( false );
222 break;
223 }
224 }
225 }
226 }
227
228 /**
229 * Register our query vars.
230 *
231 * @param string[] $vars Registered query vars.
232 * @return string[]
233 */
234 public function register_query_var( array $vars ): array {
235 $vars[] = self::QUERY_VAR;
236 $vars[] = self::TOKEN_QUERY_VAR;
237 $vars[] = self::WELLKNOWN_QUERY_VAR;
238 $vars[] = self::AUTHORIZE_QUERY_VAR;
239 return $vars;
240 }
241
242 /**
243 * Serve the MCP endpoint on the pretty path. Runs on parse_request so it
244 * fires before the main query, and short-circuits WP entirely.
245 *
246 * @param \WP $wp The WP request object.
247 * @return void
248 */
249 public function maybe_handle_pretty_endpoint( $wp ): void {
250 // OAuth discovery documents (served at the site root).
251 if ( ! empty( $wp->query_vars[ self::WELLKNOWN_QUERY_VAR ] ) ) {
252 if ( ! self::is_enabled() ) {
253 status_header( 404 );
254 exit;
255 }
256 $doc = (string) $wp->query_vars[ self::WELLKNOWN_QUERY_VAR ];
257 $data = 'authorization-server' === $doc
258 ? Mcp_OAuth::authorization_server_metadata()
259 : Mcp_OAuth::protected_resource_metadata();
260 status_header( 200 );
261 header( 'Content-Type: application/json; charset=utf-8' );
262 // Discovery metadata is public + cacheable.
263 header( 'Cache-Control: public, max-age=3600' );
264 echo wp_json_encode( $data );
265 exit;
266 }
267
268 // Browser-facing OAuth consent page (cookie auth applies here).
269 if ( ! empty( $wp->query_vars[ self::AUTHORIZE_QUERY_VAR ] ) ) {
270 if ( ! self::is_enabled() ) {
271 status_header( 404 );
272 exit;
273 }
274 $this->handle_authorize_page();
275 return;
276 }
277
278 if ( empty( $wp->query_vars[ self::QUERY_VAR ] ) ) {
279 return;
280 }
281
282 $request = new \WP_REST_Request( 'POST', '/' . self::NS . '/mcp' );
283 $request->set_header( 'content-type', 'application/json' );
284 // Carry the auth header + raw body from the live PHP request.
285 $auth = self::server_header( 'authorization' );
286 if ( null !== $auth ) {
287 $request->set_header( 'authorization', $auth );
288 }
289 // Token embedded in the URL path (/thinkrank/mcp/<token>) — surface it
290 // as a Bearer header so Mcp_Server validates it the same way. A real
291 // Authorization header (if also sent) takes precedence.
292 $path_token = isset( $wp->query_vars[ self::TOKEN_QUERY_VAR ] )
293 ? (string) $wp->query_vars[ self::TOKEN_QUERY_VAR ]
294 : '';
295 if ( '' !== $path_token && '' === (string) $request->get_header( 'authorization' ) ) {
296 $request->set_header( 'authorization', 'Bearer ' . $path_token );
297 }
298 $request->set_body( (string) file_get_contents( 'php://input' ) );
299
300 $response = Mcp_Server::handle( $request );
301 $this->emit_json( $response );
302 }
303
304 // -- REST registration --
305
306 /**
307 * Register the REST routes: the MCP JSON-RPC fallback, the admin
308 * management routes, and the OAuth registration/token endpoints.
309 *
310 * @return void
311 */
312 public function register_rest(): void {
313 // --- MCP JSON-RPC endpoint (fallback path via wp-json) -----------
314 // permission_callback is __return_true because Mcp_Server does its own
315 // token auth and must reply with a JSON-RPC 401 + WWW-Authenticate,
316 // not a bare WP permission failure.
317 register_rest_route(
318 self::NS,
319 '/mcp',
320 [
321 'methods' => 'POST',
322 'callback' => [ $this, 'rest_mcp' ],
323 'permission_callback' => '__return_true',
324 ]
325 );
326
327 // --- Admin-only management routes (the MCP page) ------------------
328 register_rest_route(
329 self::NS,
330 '/mcp/connection',
331 [
332 'methods' => 'GET',
333 'callback' => [ $this, 'rest_connection' ],
334 'permission_callback' => [ $this, 'admin_permission' ],
335 ]
336 );
337 register_rest_route(
338 self::NS,
339 '/mcp/connect',
340 [
341 'methods' => 'POST',
342 'callback' => [ $this, 'rest_connect' ],
343 'permission_callback' => [ $this, 'admin_permission' ],
344 'args' => [
345 'read_only' => [
346 'type' => 'boolean',
347 'required' => false,
348 'default' => false,
349 'description' => 'Grant read-only access (no SEO metadata or settings changes).',
350 ],
351 ],
352 ]
353 );
354 register_rest_route(
355 self::NS,
356 '/mcp/rotate',
357 [
358 'methods' => 'POST',
359 'callback' => [ $this, 'rest_rotate' ],
360 'permission_callback' => [ $this, 'admin_permission' ],
361 'args' => [
362 'read_only' => [
363 'type' => 'boolean',
364 'required' => false,
365 'description' => 'Optionally set read-only on the new token; omit to keep current scopes.',
366 ],
367 ],
368 ]
369 );
370 register_rest_route(
371 self::NS,
372 '/mcp/disconnect',
373 [
374 'methods' => 'POST',
375 'callback' => [ $this, 'rest_disconnect' ],
376 'permission_callback' => [ $this, 'admin_permission' ],
377 ]
378 );
379
380 // Live round-trip diagnostic for the MCP page (see #189). Admin-only;
381 // exercises the endpoint the way an external client would.
382 register_rest_route(
383 self::NS,
384 '/mcp/self-test',
385 [
386 'methods' => 'POST',
387 'callback' => [ $this, 'rest_self_test' ],
388 'permission_callback' => [ $this, 'admin_permission' ],
389 ]
390 );
391
392 // Connected AI apps (see #244): list the OAuth-connected clients plus a
393 // single combined row for the shared static token, and revoke either.
394 register_rest_route(
395 self::NS,
396 '/mcp/apps',
397 [
398 'methods' => 'GET',
399 'callback' => [ $this, 'rest_apps' ],
400 'permission_callback' => [ $this, 'admin_permission' ],
401 ]
402 );
403 register_rest_route(
404 self::NS,
405 '/mcp/apps/revoke',
406 [
407 'methods' => 'POST',
408 'callback' => [ $this, 'rest_revoke_app' ],
409 'permission_callback' => [ $this, 'admin_permission' ],
410 'args' => [
411 'client_id' => [
412 'type' => 'string',
413 'required' => true,
414 'description' => 'The OAuth client_id to revoke.',
415 ],
416 ],
417 ]
418 );
419
420 // --- OAuth 2.1 authorization server (the "paste a URL only" path) -
421 // Discovery, dynamic client registration, and the token endpoint are
422 // all public (permission enforced inside): a client must reach them
423 // BEFORE it holds any credential.
424 //
425 // The discovery documents are ALSO served here, not only at the
426 // /.well-known/ rewrites: hosts that resolve root /.well-known/ at
427 // their proxy edge (SiteGround) never let those requests reach
428 // WordPress, while /wp-json/ always arrives. The 401 challenge
429 // advertises this route (Mcp_OAuth::resource_metadata_url), so the
430 // flow survives on such hosts.
431 register_rest_route(
432 self::NS,
433 '/mcp/oauth/protected-resource',
434 [
435 'methods' => 'GET',
436 'callback' => [ $this, 'rest_oauth_discovery_resource' ],
437 'permission_callback' => '__return_true',
438 ]
439 );
440 register_rest_route(
441 self::NS,
442 '/mcp/oauth/authorization-server',
443 [
444 'methods' => 'GET',
445 'callback' => [ $this, 'rest_oauth_discovery_server' ],
446 'permission_callback' => '__return_true',
447 ]
448 );
449 register_rest_route(
450 self::NS,
451 '/mcp/oauth/register',
452 [
453 'methods' => 'POST',
454 'callback' => [ $this, 'rest_oauth_register' ],
455 'permission_callback' => '__return_true',
456 ]
457 );
458 // NOTE: /authorize is deliberately NOT a REST route — it is served as
459 // a normal front-end page at /thinkrank/authorize (see
460 // handle_authorize_page) so cookie auth works after wp-login.
461 register_rest_route(
462 self::NS,
463 '/mcp/oauth/token',
464 [
465 'methods' => 'POST',
466 'callback' => [ $this, 'rest_oauth_token' ],
467 'permission_callback' => '__return_true',
468 ]
469 );
470 }
471
472 /**
473 * Capability gate for the admin-only management routes.
474 *
475 * @return bool
476 */
477 public function admin_permission(): bool {
478 return current_user_can( 'manage_options' );
479 }
480
481 // -- Handlers ----------------------------------------------------------
482
483 /**
484 * MCP JSON-RPC over the wp-json fallback path.
485 *
486 * @param \WP_REST_Request $request Incoming request.
487 * @return \WP_REST_Response
488 */
489 public function rest_mcp( \WP_REST_Request $request ): \WP_REST_Response {
490 $response = Mcp_Server::handle( $request );
491 // Advertise the MCP protocol version on the wp-json transport too, so
492 // both endpoints behave identically to a strict Streamable-HTTP client.
493 $response->header( 'MCP-Protocol-Version', Mcp_Server::PROTOCOL_VERSION );
494 return $response;
495 }
496
497 /**
498 * GET /mcp/connection — pairing status for the MCP page.
499 *
500 * @return \WP_REST_Response
501 */
502 public function rest_connection(): \WP_REST_Response {
503 $this->ensure_connected();
504 return rest_ensure_response( Mcp_Pairing::public_status() );
505 }
506
507 /**
508 * Self-heal: whenever the admin views the MCP page with MCP enabled, make
509 * sure a connection token exists. New sites mint on the enable toggle (see
510 * #244), but a site that had MCP on before that behavior shipped would have
511 * no token; minting here — idempotent, admin-gated — keeps the connect
512 * recipes populated without a separate "Generate token" click.
513 *
514 * @return void
515 */
516 private function ensure_connected(): void {
517 if ( self::is_enabled() && ! Mcp_Pairing::is_connected() ) {
518 Mcp_Pairing::connect();
519 }
520 }
521
522 /**
523 * POST /mcp/connect — mint a connection token.
524 *
525 * @param \WP_REST_Request $request Carries optional read_only.
526 * @return \WP_REST_Response
527 */
528 public function rest_connect( \WP_REST_Request $request ): \WP_REST_Response {
529 $read_only = (bool) $request->get_param( 'read_only' );
530 return rest_ensure_response( Mcp_Pairing::connect( $read_only ) );
531 }
532
533 /**
534 * POST /mcp/rotate — mint a fresh token, invalidating the old one.
535 *
536 * @param \WP_REST_Request $request Carries optional read_only.
537 * @return \WP_REST_Response
538 */
539 public function rest_rotate( \WP_REST_Request $request ): \WP_REST_Response {
540 $read_only = null;
541 if ( null !== $request->get_param( 'read_only' ) ) {
542 $read_only = (bool) $request->get_param( 'read_only' );
543 }
544 return rest_ensure_response( Mcp_Pairing::rotate( $read_only ) );
545 }
546
547 /**
548 * POST /mcp/disconnect — revoke the connection token + all OAuth grants.
549 *
550 * @return \WP_REST_Response
551 */
552 public function rest_disconnect(): \WP_REST_Response {
553 return rest_ensure_response( Mcp_Pairing::disconnect() );
554 }
555
556 /**
557 * POST /mcp/self-test — run the live round-trip diagnostic (see #189).
558 *
559 * @return \WP_REST_Response
560 */
561 public function rest_self_test(): \WP_REST_Response {
562 return rest_ensure_response( Mcp_Self_Test::run() );
563 }
564
565 /**
566 * GET /mcp/apps — the "Connected AI apps" list (see #244).
567 *
568 * @return \WP_REST_Response
569 */
570 public function rest_apps(): \WP_REST_Response {
571 $this->ensure_connected();
572 return rest_ensure_response( $this->apps_payload() );
573 }
574
575 /**
576 * POST /mcp/apps/revoke — cut off a single OAuth-connected app. Returns the
577 * refreshed app list so the UI updates in one round trip. (The shared static
578 * token has no per-client identity, so it is not listed or revoked here — it
579 * is rotated from the connect card via /mcp/rotate.)
580 *
581 * @param \WP_REST_Request $request Carries target + client_id.
582 * @return \WP_REST_Response|\WP_Error
583 */
584 public function rest_revoke_app( \WP_REST_Request $request ) {
585 $client_id = (string) $request->get_param( 'client_id' );
586 if ( '' === $client_id ) {
587 return new \WP_Error(
588 'thinkrank_missing_client_id',
589 __( 'A client_id is required to revoke an OAuth app.', 'thinkrank' ),
590 [ 'status' => 400 ]
591 );
592 }
593 Mcp_OAuth::revoke_client( $client_id );
594
595 return rest_ensure_response( $this->apps_payload() );
596 }
597
598 /**
599 * Build the "Connected AI apps" payload: the OAuth-connected clients, with
600 * the approving admin's display name resolved. Header-based (static-token)
601 * clients share one anonymous secret and so are not represented here.
602 *
603 * @return array<string,mixed>
604 */
605 private function apps_payload(): array {
606 $oauth_apps = [];
607 foreach ( Mcp_OAuth::connected_apps() as $app ) {
608 $user = $app['user_id'] > 0 ? get_userdata( $app['user_id'] ) : false;
609 $oauth_apps[] = [
610 'client_id' => $app['client_id'],
611 'name' => $app['name'],
612 'read_only' => $app['read_only'],
613 'approved_by' => $user ? $user->display_name : __( 'Unknown user', 'thinkrank' ),
614 'connected_at' => $app['connected_at'],
615 'last_used' => $app['last_used'],
616 ];
617 }
618
619 return [
620 'oauth_apps' => $oauth_apps,
621 ];
622 }
623
624 // -- OAuth 2.1 handlers ------------------------------------------------
625
626 /**
627 * GET /mcp/oauth/protected-resource — RFC 9728 metadata via REST.
628 *
629 * @return \WP_REST_Response|\WP_Error
630 */
631 public function rest_oauth_discovery_resource() {
632 return $this->oauth_discovery_response( Mcp_OAuth::protected_resource_metadata() );
633 }
634
635 /**
636 * GET /mcp/oauth/authorization-server — RFC 8414 metadata via REST.
637 *
638 * @return \WP_REST_Response|\WP_Error
639 */
640 public function rest_oauth_discovery_server() {
641 return $this->oauth_discovery_response( Mcp_OAuth::authorization_server_metadata() );
642 }
643
644 /**
645 * Shape one discovery document response: public, cacheable, and 404 when
646 * MCP is off — matching the /.well-known/ rewrites exactly, so a client
647 * sees the same truth regardless of which serving path reached it.
648 *
649 * @param array<string,mixed> $document Discovery metadata.
650 * @return \WP_REST_Response|\WP_Error
651 */
652 private function oauth_discovery_response( array $document ) {
653 if ( ! self::is_enabled() ) {
654 return new \WP_Error( 'thinkrank_mcp_disabled', __( 'MCP is disabled on this site.', 'thinkrank' ), [ 'status' => 404 ] );
655 }
656 $response = new \WP_REST_Response( $document, 200 );
657 $response->header( 'Cache-Control', 'public, max-age=3600' );
658 return $response;
659 }
660
661 /**
662 * POST /mcp/oauth/register — RFC 7591 dynamic client registration.
663 *
664 * @param \WP_REST_Request $request JSON body with redirect_uris.
665 * @return \WP_REST_Response|\WP_Error
666 */
667 public function rest_oauth_register( \WP_REST_Request $request ) {
668 if ( ! self::is_enabled() ) {
669 return new \WP_Error( 'thinkrank_mcp_disabled', __( 'MCP is disabled on this site.', 'thinkrank' ), [ 'status' => 403 ] );
670 }
671 $body = $request->get_json_params();
672 if ( ! is_array( $body ) ) {
673 $body = [];
674 }
675 $result = Mcp_OAuth::register_client( $body );
676 if ( is_wp_error( $result ) ) {
677 return $result;
678 }
679 return new \WP_REST_Response( $result, 201 );
680 }
681
682 /**
683 * POST /mcp/oauth/token — exchange a code (or refresh token) for tokens.
684 *
685 * @param \WP_REST_Request $request Form-encoded or JSON token request.
686 * @return \WP_REST_Response
687 */
688 public function rest_oauth_token( \WP_REST_Request $request ): \WP_REST_Response {
689 if ( ! self::is_enabled() ) {
690 $response = new \WP_REST_Response(
691 [
692 'error' => 'invalid_request',
693 'error_description' => 'MCP is disabled on this site.',
694 ],
695 403
696 );
697 $response->header( 'Cache-Control', 'no-store' );
698 return $response;
699 }
700
701 // Token requests are application/x-www-form-urlencoded per OAuth, but
702 // accept JSON too. get_body_params() covers the form case.
703 $body = $request->get_body_params();
704 if ( empty( $body ) ) {
705 $json = $request->get_json_params();
706 $body = is_array( $json ) ? $json : [];
707 }
708 $body = array_map( 'strval', $body );
709
710 $result = Mcp_OAuth::exchange_token( $body );
711 if ( is_wp_error( $result ) ) {
712 $data = $result->get_error_data();
713 $response = new \WP_REST_Response(
714 [
715 'error' => isset( $data['error'] ) ? $data['error'] : 'invalid_request',
716 'error_description' => isset( $data['error_description'] ) ? $data['error_description'] : $result->get_error_message(),
717 ],
718 isset( $data['status'] ) ? (int) $data['status'] : 400
719 );
720 $response->header( 'Cache-Control', 'no-store' );
721 return $response;
722 }
723 $response = new \WP_REST_Response( $result, 200 );
724 $response->header( 'Cache-Control', 'no-store' );
725 $response->header( 'Pragma', 'no-cache' );
726 return $response;
727 }
728
729 // -- OAuth authorize page ------------------------------------------------
730
731 /**
732 * The browser-facing OAuth authorize page (served at /thinkrank/authorize
733 * via a rewrite, NOT the REST API). Reads request params from the
734 * superglobals because this is a normal front-end request where cookie
735 * auth populates is_user_logged_in().
736 *
737 * GET renders the consent screen (requires a logged-in admin; anonymous
738 * users go to wp-login and return here). POST is the nonce-checked consent
739 * submission: Approve issues a code and 302s to the client's redirect_uri;
740 * Deny 302s back with error=access_denied. Always emits its own response
741 * (HTML page or redirect) and exits.
742 *
743 * @return void
744 */
745 public function handle_authorize_page(): void {
746 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- compared against a literal after strtoupper(); nothing is stored or echoed.
747 $is_post = isset( $_SERVER['REQUEST_METHOD'] ) && 'POST' === strtoupper( (string) wp_unslash( $_SERVER['REQUEST_METHOD'] ) );
748 // Params come from GET on the consent link and POST on the form submit.
749 // Nonce is verified below before any POST value is acted on.
750 // phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- read verbatim by oauth_param(); see its docblock for why, and where each value is validated or escaped instead.
751 $source = $is_post ? $_POST : $_GET;
752 // phpcs:enable WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
753 $params = [];
754 foreach ( [ 'client_id', 'redirect_uri', 'response_type', 'code_challenge', 'code_challenge_method', 'scope', 'state', 'approve', 'deny', '_thinkrank_oauth_nonce' ] as $k ) {
755 $params[ $k ] = self::oauth_param( $source, $k );
756 }
757
758 // Validate the OAuth params before touching the session.
759 $req = Mcp_OAuth::validate_authorize_request( $params );
760 if ( is_wp_error( $req ) ) {
761 $data = $req->get_error_data();
762 $redirectable = is_array( $data ) && ! empty( $data['redirectable'] );
763 // Only redirect the error back when redirect_uri is verified valid;
764 // otherwise show a page (never bounce to an unverified URL).
765 if ( $redirectable && '' !== $params['redirect_uri'] ) {
766 $this->redirect_error( $params['redirect_uri'], $req->get_error_code(), $req->get_error_message(), $params['state'] );
767 }
768 $this->emit_oauth_error_page( $req->get_error_message() );
769 }
770
771 // Require a logged-in admin. Anonymous → wp-login, back to this URL.
772 if ( ! is_user_logged_in() ) {
773 $this->redirect_to_login();
774 }
775 if ( ! current_user_can( 'manage_options' ) ) {
776 $this->emit_oauth_error_page(
777 __( 'You must be an administrator to authorize an AI assistant to manage SEO on this site.', 'thinkrank' )
778 );
779 }
780
781 // POST = consent form submitted.
782 if ( $is_post ) {
783 if ( ! wp_verify_nonce( $params['_thinkrank_oauth_nonce'], 'thinkrank_oauth_consent' ) ) {
784 $this->emit_oauth_error_page( __( 'Security check failed. Please try connecting again.', 'thinkrank' ) );
785 }
786 if ( '' === $params['approve'] ) {
787 $this->redirect_error( $req['redirect_uri'], 'access_denied', 'The user denied the request.', $req['state'] );
788 }
789 $code = Mcp_OAuth::issue_code( $req, get_current_user_id() );
790 $this->redirect_success( $req['redirect_uri'], $code, $req['state'] );
791 }
792
793 // GET = render the consent screen.
794 $this->emit_consent_screen( $req );
795 }
796
797 // -- OAuth browser-response helpers ------------------------------------
798
799 /**
800 * The absolute URL of the current authorize request (for login return).
801 *
802 * @return string
803 */
804 private function current_authorize_url(): string {
805 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- reconstructing the current URL for a login round-trip; escaped at use.
806 $uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '';
807 return home_url( $uri );
808 }
809
810 /**
811 * Send an anonymous visitor to wp-login, returning to this authorize URL.
812 *
813 * @return void
814 */
815 private function redirect_to_login(): void {
816 wp_safe_redirect( wp_login_url( $this->current_authorize_url() ) );
817 exit;
818 }
819
820 /**
821 * 302 back to the client with the authorization code (+ state).
822 *
823 * @param string $redirect_uri Validated client redirect URI.
824 * @param string $code Authorization code.
825 * @param string $state Client state.
826 * @return void
827 */
828 private function redirect_success( string $redirect_uri, string $code, string $state ): void {
829 $args = [ 'code' => $code ];
830 if ( '' !== $state ) {
831 $args['state'] = $state;
832 }
833 // Not wp_safe_redirect: redirect_uri is a client-registered off-site
834 // callback, already validated against the client's registered set.
835 wp_redirect( add_query_arg( $args, $redirect_uri ) ); // phpcs:ignore WordPress.Security.SafeRedirect -- validated OAuth redirect_uri.
836 exit;
837 }
838
839 /**
840 * 302 back to the client with an OAuth error (+ state).
841 *
842 * @param string $redirect_uri Validated client redirect URI.
843 * @param string $error OAuth error code.
844 * @param string $description Human-readable description.
845 * @param string $state Client state.
846 * @return void
847 */
848 private function redirect_error( string $redirect_uri, string $error, string $description, string $state ): void {
849 $args = [
850 'error' => $error,
851 'error_description' => $description,
852 ];
853 if ( '' !== $state ) {
854 $args['state'] = $state;
855 }
856 wp_redirect( add_query_arg( array_map( 'rawurlencode', $args ), $redirect_uri ) ); // phpcs:ignore WordPress.Security.SafeRedirect -- validated OAuth redirect_uri.
857 exit;
858 }
859
860 /**
861 * Render the consent screen. Minimal self-contained HTML (no admin
862 * chrome — this is a client-facing OAuth page). Approve/Deny post back
863 * to the same authorize URL with a nonce.
864 *
865 * @param array<string,string> $req Validated authorize params.
866 * @return void
867 */
868 private function emit_consent_screen( array $req ): void {
869 $read_only = Mcp_OAuth::scope_is_read_only( $req['scope'] );
870 $access_label = $read_only ? __( 'Read-only', 'thinkrank' ) : __( 'Read & write', 'thinkrank' );
871 $access_desc = $read_only
872 ? __( 'Review your SEO across posts and site settings metadata, schema, sitemaps, robots, social, and SEO scores. No changes are made.', 'thinkrank' )
873 : __( 'Read and improve your SEO across posts and site settings metadata, schema, sitemaps, robots, social, indexing, and SEO scores.', 'thinkrank' );
874 $client = '' !== $req['client_name'] ? $req['client_name'] : __( 'An AI assistant', 'thinkrank' );
875 $action_url = Mcp_OAuth::authorize_url();
876 $nonce = wp_create_nonce( 'thinkrank_oauth_consent' );
877 $user = wp_get_current_user();
878
879 // Preserve every OAuth param so the POST re-validates identically.
880 $hidden = '';
881 foreach ( [ 'client_id', 'redirect_uri', 'code_challenge', 'scope', 'state' ] as $k ) {
882 $val = 'scope' === $k ? $req['scope'] : ( $req[ $k ] ?? '' );
883 $hidden .= sprintf( '<input type="hidden" name="%s" value="%s" />', esc_attr( $k ), esc_attr( (string) $val ) );
884 }
885 // code_challenge_method + response_type are re-asserted for validation.
886 $hidden .= '<input type="hidden" name="code_challenge_method" value="S256" />';
887 $hidden .= '<input type="hidden" name="response_type" value="code" />';
888
889 status_header( 200 );
890 header( 'Content-Type: text/html; charset=utf-8' );
891 header( 'Cache-Control: no-store' );
892
893 $host = (string) wp_parse_url( home_url(), PHP_URL_HOST );
894 $logo = '<svg width="36" height="36" viewBox="0 0 29 29" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">'
895 . '<g clip-path="url(#tr_clip0)">'
896 . '<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)"/>'
897 . '<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)"/>'
898 . '<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)"/>'
899 . '<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"/>'
900 . '<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)"/>'
901 . '<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)"/>'
902 . '</g>'
903 . '<rect x="0.353516" y="0.353516" width="28" height="28" rx="7.76216" stroke="#B6CBFF" stroke-width="0.707321"/>'
904 . '<defs>'
905 . '<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>'
906 . '<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>'
907 . '<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>'
908 . '<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>'
909 . '<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>'
910 . '<clipPath id="tr_clip0"><rect x="0.353516" y="0.353516" width="28" height="28" rx="7.76216" fill="white"/></clipPath>'
911 . '</defs></svg>';
912 $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>';
913
914 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>';
915 echo '<style>'
916 . ':root{color-scheme:dark}*{box-sizing:border-box}'
917 . '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}'
918 . '.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)}'
919 . '.brand{display:flex;align-items:center;gap:10px;margin-bottom:22px}'
920 . '.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)}'
921 . '.brand b{font-size:14px;font-weight:700;letter-spacing:.02em}'
922 . 'h1{font-size:20px;font-weight:700;margin:0 0 6px}'
923 . '.sub{color:#9aa6be;font-size:13.5px;margin:0 0 22px}.sub strong{color:#e7ecf3;font-weight:600}'
924 . '.rows{border:1px solid #263149;border-radius:14px;overflow:hidden;margin-bottom:16px}'
925 . '.row{display:flex;justify-content:space-between;align-items:center;gap:16px;padding:13px 16px;font-size:13.5px}'
926 . '.row+.row,.access{border-top:1px solid #263149}'
927 . '.row .k{color:#9aa6be}.row .v{font-weight:600;text-align:right;word-break:break-word}'
928 . '.access{padding:14px 16px;background:rgba(99,102,241,.07)}'
929 . '.access .k{color:#9aa6be;font-size:13px;margin-bottom:8px}'
930 . '.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)}'
931 . '.badge.ro{background:rgba(245,158,11,.15);color:#fcd9a1;border-color:rgba(245,158,11,.4)}'
932 . '.access .d{color:#c3ccdd;font-size:13px;margin-top:8px}'
933 . '.note{display:flex;align-items:center;gap:7px;color:#7f8aa3;font-size:12px;margin:0 0 20px}'
934 . '.actions{display:flex;gap:12px}'
935 . '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)}'
936 . '.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)}'
937 . '.deny{background:transparent;color:#aeb8cc;border:1px solid #33405c}.deny:hover{background:rgba(255,255,255,.04)}'
938 . '</style></head><body><div class="card">';
939
940 echo '<div class="brand"><span class="logo">' . $logo . '</span><b>ThinkRank</b></div>'; // phpcs:ignore WordPress.Security.EscapeOutput -- static markup.
941 echo '<h1>' . esc_html__( 'Connect to ThinkRank', 'thinkrank' ) . '</h1>';
942 $sub = sprintf(
943 /* translators: %s: AI client name, already escaped and wrapped in <strong>. */
944 esc_html__( '%s wants to manage SEO on this site.', 'thinkrank' ),
945 '<strong>' . esc_html( $client ) . '</strong>'
946 );
947 echo '<p class="sub">' . $sub . '</p>'; // phpcs:ignore WordPress.Security.EscapeOutput -- static translation; client name esc_html'd.
948
949 echo '<div class="rows">';
950 echo '<div class="row"><span class="k">' . esc_html__( 'Site', 'thinkrank' ) . '</span><span class="v">' . esc_html( $host ) . '</span></div>';
951 echo '<div class="row"><span class="k">' . esc_html__( 'Signed in as', 'thinkrank' ) . '</span><span class="v">' . esc_html( $user->user_login ) . '</span></div>';
952 echo '<div class="access"><div class="k">' . esc_html__( 'Access', 'thinkrank' ) . '</div>';
953 echo '<span class="badge ' . ( $read_only ? 'ro' : '' ) . '">' . esc_html( $access_label ) . '</span>';
954 echo '<div class="d">' . esc_html( $access_desc ) . '</div></div>';
955 echo '</div>';
956
957 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.
958
959 echo '<form method="post" action="' . esc_url( $action_url ) . '">';
960 echo $hidden; // phpcs:ignore WordPress.Security.EscapeOutput -- built from esc_attr() above.
961 echo '<input type="hidden" name="_thinkrank_oauth_nonce" value="' . esc_attr( $nonce ) . '" />';
962 echo '<div class="actions">';
963 echo '<button class="deny" name="deny" value="1">' . esc_html__( 'Deny', 'thinkrank' ) . '</button>';
964 echo '<button class="approve" name="approve" value="1">' . esc_html__( 'Approve', 'thinkrank' ) . '</button>';
965 echo '</div></form></div></body></html>';
966 exit;
967 }
968
969 /**
970 * Render a standalone OAuth error page (no redirect).
971 *
972 * @param string $message Error message.
973 * @return void
974 */
975 private function emit_oauth_error_page( string $message ): void {
976 status_header( 400 );
977 header( 'Content-Type: text/html; charset=utf-8' );
978 header( 'Cache-Control: no-store' );
979 echo '<!doctype html><html><head><meta charset="utf-8"><title>' . esc_html__( 'Authorization error', 'thinkrank' ) . '</title>';
980 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}'
981 . '.card{background:#1e293b;border:1px solid #334155;border-radius:16px;max-width:440px;padding:32px;text-align:center}</style></head><body>';
982 echo '<div class="card"><h1>' . esc_html__( 'Could not authorize', 'thinkrank' ) . '</h1><p>' . esc_html( $message ) . '</p></div></body></html>';
983 exit;
984 }
985
986 // -- Helpers --
987
988 /**
989 * Read one /authorize parameter verbatim.
990 *
991 * Deliberately NOT sanitize_text_field(). That function exists to make
992 * untrusted text safe to store and display, and part of what it does is
993 * strip %XX sequences as an anti-obfuscation measure. Applied to an OAuth
994 * protocol value it quietly changes the value's meaning.
995 *
996 * The concrete failure: registration stores redirect_uris raw from a JSON
997 * body, but at /authorize the same URI arrives as a query parameter, so
998 * PHP has already URL-decoded it — and sanitising then removed the percent
999 * sequences. A client registered with `.../cb?next=%2Fdashboard` was
1000 * compared as `.../cb?next=dashboard`, failed the strict match, and was
1001 * told `invalid_redirect_uri` for sending exactly what it registered
1002 * (#487). `state` has the same problem: it is opaque to us and must
1003 * round-trip byte for byte, or the client aborts its own callback.
1004 *
1005 * Protocol identifiers want validation and rejection, not cleaning. Every
1006 * value read here is constrained somewhere better suited to it:
1007 * - redirect_uri strict in_array() against the client's registered set
1008 * - client_id must resolve to a registered client
1009 * - response_type must equal 'code'
1010 * - code_challenge_method must equal 'S256'
1011 * - code_challenge validated against the RFC 7636 character set
1012 * - scope intersected with SUPPORTED_SCOPES
1013 * - state opaque; escaped at output (esc_attr / rawurlencode)
1014 * - approve/deny tested for emptiness only
1015 * - the nonce passed to wp_verify_nonce()
1016 *
1017 * An array value (`?state[]=x`) reads as absent rather than becoming the
1018 * string "Array".
1019 *
1020 * @since 2.1.0
1021 *
1022 * @param array<string,mixed> $source $_GET or $_POST.
1023 * @param string $key Parameter name.
1024 * @return string
1025 */
1026 private static function oauth_param( array $source, string $key ): string {
1027 if ( ! isset( $source[ $key ] ) || ! is_scalar( $source[ $key ] ) ) {
1028 return '';
1029 }
1030
1031 return (string) wp_unslash( $source[ $key ] );
1032 }
1033
1034 /**
1035 * Read an inbound HTTP header from $_SERVER (for the pretty path).
1036 *
1037 * Mirrors WP_REST_Server::get_headers(): on Apache with CGI/FastCGI/suPHP the
1038 * Authorization header never lands in HTTP_AUTHORIZATION. WordPress's own
1039 * .htaccess passthrough re-publishes it as REDIRECT_HTTP_AUTHORIZATION, and a
1040 * few Apache module setups populate neither key but do answer getallheaders().
1041 * The REST route gets this handling from core; the pretty route builds its own
1042 * WP_REST_Request, so it has to do the same here or it 401s on those hosts.
1043 *
1044 * @param string $name Header name.
1045 * @return string|null
1046 */
1047 private static function server_header( string $name ): ?string {
1048 $key = 'HTTP_' . strtoupper( str_replace( '-', '_', $name ) );
1049
1050 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- token compared constant-time downstream; raw header needed verbatim.
1051 if ( isset( $_SERVER[ $key ] ) && '' !== $_SERVER[ $key ] ) {
1052 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- as above.
1053 return wp_unslash( $_SERVER[ $key ] );
1054 }
1055
1056 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- as above.
1057 if ( isset( $_SERVER[ 'REDIRECT_' . $key ] ) && '' !== $_SERVER[ 'REDIRECT_' . $key ] ) {
1058 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- as above.
1059 return wp_unslash( $_SERVER[ 'REDIRECT_' . $key ] );
1060 }
1061
1062 if ( function_exists( 'getallheaders' ) ) {
1063 $headers = getallheaders();
1064 if ( is_array( $headers ) ) {
1065 foreach ( $headers as $header => $value ) {
1066 if ( 0 === strcasecmp( (string) $header, $name ) && '' !== (string) $value ) {
1067 return (string) $value;
1068 }
1069 }
1070 }
1071 }
1072
1073 return null;
1074 }
1075
1076 /**
1077 * Emit a WP_REST_Response as a JSON HTTP response and stop.
1078 *
1079 * @param \WP_REST_Response $response Response to emit.
1080 * @return void
1081 */
1082 private function emit_json( \WP_REST_Response $response ): void {
1083 status_header( $response->get_status() );
1084 // MCP Streamable HTTP: advertise the protocol version we speak so a
1085 // strict client can pin it. We answer JSON (a spec-permitted response
1086 // type); we never open an SSE stream, so no session header is needed.
1087 header( 'MCP-Protocol-Version: ' . Mcp_Server::PROTOCOL_VERSION );
1088 // Never cached. The pretty endpoint can carry the pairing token in its
1089 // path, so a shared cache or proxy holding a response keyed on that URL
1090 // would keep an admin-equivalent credential in its store (#396).
1091 header( 'Cache-Control: no-store, private' );
1092 // Forward any headers the handler set (notably WWW-Authenticate on a
1093 // 401, which drives the OAuth discovery flow).
1094 foreach ( $response->get_headers() as $name => $value ) {
1095 // Re-assert the status on every header: PHP special-cases
1096 // WWW-Authenticate and forces a 401 when no status is given,
1097 // which would silently mask the 429 lockout response.
1098 header( $name . ': ' . $value, true, $response->get_status() );
1099 }
1100 $data = $response->get_data();
1101 if ( null !== $data ) {
1102 header( 'Content-Type: application/json; charset=utf-8' );
1103 echo wp_json_encode( $data );
1104 }
1105 exit;
1106 }
1107 }
1108