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

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