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

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

962 lines 42.2 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 register_rest_route(
419 self::NS,
420 '/mcp/oauth/register',
421 [
422 'methods' => 'POST',
423 'callback' => [ $this, 'rest_oauth_register' ],
424 'permission_callback' => '__return_true',
425 ]
426 );
427 // NOTE: /authorize is deliberately NOT a REST route — it is served as
428 // a normal front-end page at /thinkrank/authorize (see
429 // handle_authorize_page) so cookie auth works after wp-login.
430 register_rest_route(
431 self::NS,
432 '/mcp/oauth/token',
433 [
434 'methods' => 'POST',
435 'callback' => [ $this, 'rest_oauth_token' ],
436 'permission_callback' => '__return_true',
437 ]
438 );
439 }
440
441 /**
442 * Capability gate for the admin-only management routes.
443 *
444 * @return bool
445 */
446 public function admin_permission(): bool {
447 return current_user_can( 'manage_options' );
448 }
449
450 // -- Handlers ----------------------------------------------------------
451
452 /**
453 * MCP JSON-RPC over the wp-json fallback path.
454 *
455 * @param \WP_REST_Request $request Incoming request.
456 * @return \WP_REST_Response
457 */
458 public function rest_mcp( \WP_REST_Request $request ): \WP_REST_Response {
459 $response = Mcp_Server::handle( $request );
460 // Advertise the MCP protocol version on the wp-json transport too, so
461 // both endpoints behave identically to a strict Streamable-HTTP client.
462 $response->header( 'MCP-Protocol-Version', Mcp_Server::PROTOCOL_VERSION );
463 return $response;
464 }
465
466 /**
467 * GET /mcp/connection — pairing status for the MCP page.
468 *
469 * @return \WP_REST_Response
470 */
471 public function rest_connection(): \WP_REST_Response {
472 $this->ensure_connected();
473 return rest_ensure_response( Mcp_Pairing::public_status() );
474 }
475
476 /**
477 * Self-heal: whenever the admin views the MCP page with MCP enabled, make
478 * sure a connection token exists. New sites mint on the enable toggle (see
479 * #244), but a site that had MCP on before that behavior shipped would have
480 * no token; minting here — idempotent, admin-gated — keeps the connect
481 * recipes populated without a separate "Generate token" click.
482 *
483 * @return void
484 */
485 private function ensure_connected(): void {
486 if ( self::is_enabled() && ! Mcp_Pairing::is_connected() ) {
487 Mcp_Pairing::connect();
488 }
489 }
490
491 /**
492 * POST /mcp/connect — mint a connection token.
493 *
494 * @param \WP_REST_Request $request Carries optional read_only.
495 * @return \WP_REST_Response
496 */
497 public function rest_connect( \WP_REST_Request $request ): \WP_REST_Response {
498 $read_only = (bool) $request->get_param( 'read_only' );
499 return rest_ensure_response( Mcp_Pairing::connect( $read_only ) );
500 }
501
502 /**
503 * POST /mcp/rotate — mint a fresh token, invalidating the old one.
504 *
505 * @param \WP_REST_Request $request Carries optional read_only.
506 * @return \WP_REST_Response
507 */
508 public function rest_rotate( \WP_REST_Request $request ): \WP_REST_Response {
509 $read_only = null;
510 if ( null !== $request->get_param( 'read_only' ) ) {
511 $read_only = (bool) $request->get_param( 'read_only' );
512 }
513 return rest_ensure_response( Mcp_Pairing::rotate( $read_only ) );
514 }
515
516 /**
517 * POST /mcp/disconnect — revoke the connection token + all OAuth grants.
518 *
519 * @return \WP_REST_Response
520 */
521 public function rest_disconnect(): \WP_REST_Response {
522 return rest_ensure_response( Mcp_Pairing::disconnect() );
523 }
524
525 /**
526 * POST /mcp/self-test — run the live round-trip diagnostic (see #189).
527 *
528 * @return \WP_REST_Response
529 */
530 public function rest_self_test(): \WP_REST_Response {
531 return rest_ensure_response( Mcp_Self_Test::run() );
532 }
533
534 /**
535 * GET /mcp/apps — the "Connected AI apps" list (see #244).
536 *
537 * @return \WP_REST_Response
538 */
539 public function rest_apps(): \WP_REST_Response {
540 $this->ensure_connected();
541 return rest_ensure_response( $this->apps_payload() );
542 }
543
544 /**
545 * POST /mcp/apps/revoke — cut off a single OAuth-connected app. Returns the
546 * refreshed app list so the UI updates in one round trip. (The shared static
547 * token has no per-client identity, so it is not listed or revoked here — it
548 * is rotated from the connect card via /mcp/rotate.)
549 *
550 * @param \WP_REST_Request $request Carries target + client_id.
551 * @return \WP_REST_Response|\WP_Error
552 */
553 public function rest_revoke_app( \WP_REST_Request $request ) {
554 $client_id = (string) $request->get_param( 'client_id' );
555 if ( '' === $client_id ) {
556 return new \WP_Error(
557 'thinkrank_missing_client_id',
558 __( 'A client_id is required to revoke an OAuth app.', 'thinkrank' ),
559 [ 'status' => 400 ]
560 );
561 }
562 Mcp_OAuth::revoke_client( $client_id );
563
564 return rest_ensure_response( $this->apps_payload() );
565 }
566
567 /**
568 * Build the "Connected AI apps" payload: the OAuth-connected clients, with
569 * the approving admin's display name resolved. Header-based (static-token)
570 * clients share one anonymous secret and so are not represented here.
571 *
572 * @return array<string,mixed>
573 */
574 private function apps_payload(): array {
575 $oauth_apps = [];
576 foreach ( Mcp_OAuth::connected_apps() as $app ) {
577 $user = $app['user_id'] > 0 ? get_userdata( $app['user_id'] ) : false;
578 $oauth_apps[] = [
579 'client_id' => $app['client_id'],
580 'name' => $app['name'],
581 'read_only' => $app['read_only'],
582 'approved_by' => $user ? $user->display_name : __( 'Unknown user', 'thinkrank' ),
583 'connected_at' => $app['connected_at'],
584 'last_used' => $app['last_used'],
585 ];
586 }
587
588 return [
589 'oauth_apps' => $oauth_apps,
590 ];
591 }
592
593 // -- OAuth 2.1 handlers ------------------------------------------------
594
595 /**
596 * POST /mcp/oauth/register — RFC 7591 dynamic client registration.
597 *
598 * @param \WP_REST_Request $request JSON body with redirect_uris.
599 * @return \WP_REST_Response|\WP_Error
600 */
601 public function rest_oauth_register( \WP_REST_Request $request ) {
602 if ( ! self::is_enabled() ) {
603 return new \WP_Error( 'thinkrank_mcp_disabled', __( 'MCP is disabled on this site.', 'thinkrank' ), [ 'status' => 403 ] );
604 }
605 $body = $request->get_json_params();
606 if ( ! is_array( $body ) ) {
607 $body = [];
608 }
609 $result = Mcp_OAuth::register_client( $body );
610 if ( is_wp_error( $result ) ) {
611 return $result;
612 }
613 return new \WP_REST_Response( $result, 201 );
614 }
615
616 /**
617 * POST /mcp/oauth/token — exchange a code (or refresh token) for tokens.
618 *
619 * @param \WP_REST_Request $request Form-encoded or JSON token request.
620 * @return \WP_REST_Response
621 */
622 public function rest_oauth_token( \WP_REST_Request $request ): \WP_REST_Response {
623 if ( ! self::is_enabled() ) {
624 $response = new \WP_REST_Response(
625 [
626 'error' => 'invalid_request',
627 'error_description' => 'MCP is disabled on this site.',
628 ],
629 403
630 );
631 $response->header( 'Cache-Control', 'no-store' );
632 return $response;
633 }
634
635 // Token requests are application/x-www-form-urlencoded per OAuth, but
636 // accept JSON too. get_body_params() covers the form case.
637 $body = $request->get_body_params();
638 if ( empty( $body ) ) {
639 $json = $request->get_json_params();
640 $body = is_array( $json ) ? $json : [];
641 }
642 $body = array_map( 'strval', $body );
643
644 $result = Mcp_OAuth::exchange_token( $body );
645 if ( is_wp_error( $result ) ) {
646 $data = $result->get_error_data();
647 $response = new \WP_REST_Response(
648 [
649 'error' => isset( $data['error'] ) ? $data['error'] : 'invalid_request',
650 'error_description' => isset( $data['error_description'] ) ? $data['error_description'] : $result->get_error_message(),
651 ],
652 isset( $data['status'] ) ? (int) $data['status'] : 400
653 );
654 $response->header( 'Cache-Control', 'no-store' );
655 return $response;
656 }
657 $response = new \WP_REST_Response( $result, 200 );
658 $response->header( 'Cache-Control', 'no-store' );
659 $response->header( 'Pragma', 'no-cache' );
660 return $response;
661 }
662
663 // -- OAuth authorize page ------------------------------------------------
664
665 /**
666 * The browser-facing OAuth authorize page (served at /thinkrank/authorize
667 * via a rewrite, NOT the REST API). Reads request params from the
668 * superglobals because this is a normal front-end request where cookie
669 * auth populates is_user_logged_in().
670 *
671 * GET renders the consent screen (requires a logged-in admin; anonymous
672 * users go to wp-login and return here). POST is the nonce-checked consent
673 * submission: Approve issues a code and 302s to the client's redirect_uri;
674 * Deny 302s back with error=access_denied. Always emits its own response
675 * (HTML page or redirect) and exits.
676 *
677 * @return void
678 */
679 public function handle_authorize_page(): void {
680 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- compared against a literal after strtoupper(); nothing is stored or echoed.
681 $is_post = isset( $_SERVER['REQUEST_METHOD'] ) && 'POST' === strtoupper( (string) wp_unslash( $_SERVER['REQUEST_METHOD'] ) );
682 // Params come from GET on the consent link and POST on the form submit.
683 // Nonce is verified below before any POST value is acted on.
684 // 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.
685 $source = $is_post ? $_POST : $_GET;
686 // phpcs:enable WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
687 $params = [];
688 foreach ( [ 'client_id', 'redirect_uri', 'response_type', 'code_challenge', 'code_challenge_method', 'scope', 'state', 'approve', 'deny', '_thinkrank_oauth_nonce' ] as $k ) {
689 $params[ $k ] = isset( $source[ $k ] ) ? sanitize_text_field( wp_unslash( $source[ $k ] ) ) : '';
690 }
691
692 // Validate the OAuth params before touching the session.
693 $req = Mcp_OAuth::validate_authorize_request( $params );
694 if ( is_wp_error( $req ) ) {
695 $data = $req->get_error_data();
696 $redirectable = is_array( $data ) && ! empty( $data['redirectable'] );
697 // Only redirect the error back when redirect_uri is verified valid;
698 // otherwise show a page (never bounce to an unverified URL).
699 if ( $redirectable && '' !== $params['redirect_uri'] ) {
700 $this->redirect_error( $params['redirect_uri'], $req->get_error_code(), $req->get_error_message(), $params['state'] );
701 }
702 $this->emit_oauth_error_page( $req->get_error_message() );
703 }
704
705 // Require a logged-in admin. Anonymous → wp-login, back to this URL.
706 if ( ! is_user_logged_in() ) {
707 $this->redirect_to_login();
708 }
709 if ( ! current_user_can( 'manage_options' ) ) {
710 $this->emit_oauth_error_page(
711 __( 'You must be an administrator to authorize an AI assistant to manage SEO on this site.', 'thinkrank' )
712 );
713 }
714
715 // POST = consent form submitted.
716 if ( $is_post ) {
717 if ( ! wp_verify_nonce( $params['_thinkrank_oauth_nonce'], 'thinkrank_oauth_consent' ) ) {
718 $this->emit_oauth_error_page( __( 'Security check failed. Please try connecting again.', 'thinkrank' ) );
719 }
720 if ( '' === $params['approve'] ) {
721 $this->redirect_error( $req['redirect_uri'], 'access_denied', 'The user denied the request.', $req['state'] );
722 }
723 $code = Mcp_OAuth::issue_code( $req, get_current_user_id() );
724 $this->redirect_success( $req['redirect_uri'], $code, $req['state'] );
725 }
726
727 // GET = render the consent screen.
728 $this->emit_consent_screen( $req );
729 }
730
731 // -- OAuth browser-response helpers ------------------------------------
732
733 /**
734 * The absolute URL of the current authorize request (for login return).
735 *
736 * @return string
737 */
738 private function current_authorize_url(): string {
739 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- reconstructing the current URL for a login round-trip; escaped at use.
740 $uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '';
741 return home_url( $uri );
742 }
743
744 /**
745 * Send an anonymous visitor to wp-login, returning to this authorize URL.
746 *
747 * @return void
748 */
749 private function redirect_to_login(): void {
750 wp_safe_redirect( wp_login_url( $this->current_authorize_url() ) );
751 exit;
752 }
753
754 /**
755 * 302 back to the client with the authorization code (+ state).
756 *
757 * @param string $redirect_uri Validated client redirect URI.
758 * @param string $code Authorization code.
759 * @param string $state Client state.
760 * @return void
761 */
762 private function redirect_success( string $redirect_uri, string $code, string $state ): void {
763 $args = [ 'code' => $code ];
764 if ( '' !== $state ) {
765 $args['state'] = $state;
766 }
767 // Not wp_safe_redirect: redirect_uri is a client-registered off-site
768 // callback, already validated against the client's registered set.
769 wp_redirect( add_query_arg( $args, $redirect_uri ) ); // phpcs:ignore WordPress.Security.SafeRedirect -- validated OAuth redirect_uri.
770 exit;
771 }
772
773 /**
774 * 302 back to the client with an OAuth error (+ state).
775 *
776 * @param string $redirect_uri Validated client redirect URI.
777 * @param string $error OAuth error code.
778 * @param string $description Human-readable description.
779 * @param string $state Client state.
780 * @return void
781 */
782 private function redirect_error( string $redirect_uri, string $error, string $description, string $state ): void {
783 $args = [
784 'error' => $error,
785 'error_description' => $description,
786 ];
787 if ( '' !== $state ) {
788 $args['state'] = $state;
789 }
790 wp_redirect( add_query_arg( array_map( 'rawurlencode', $args ), $redirect_uri ) ); // phpcs:ignore WordPress.Security.SafeRedirect -- validated OAuth redirect_uri.
791 exit;
792 }
793
794 /**
795 * Render the consent screen. Minimal self-contained HTML (no admin
796 * chrome — this is a client-facing OAuth page). Approve/Deny post back
797 * to the same authorize URL with a nonce.
798 *
799 * @param array<string,string> $req Validated authorize params.
800 * @return void
801 */
802 private function emit_consent_screen( array $req ): void {
803 $read_only = Mcp_OAuth::scope_is_read_only( $req['scope'] );
804 $access_label = $read_only ? __( 'Read-only', 'thinkrank' ) : __( 'Read & write', 'thinkrank' );
805 $access_desc = $read_only
806 ? __( 'Review your SEO across posts and site settings — metadata, schema, sitemaps, robots, social, and SEO scores. No changes are made.', 'thinkrank' )
807 : __( 'Read and improve your SEO across posts and site settings — metadata, schema, sitemaps, robots, social, indexing, and SEO scores.', 'thinkrank' );
808 $client = '' !== $req['client_name'] ? $req['client_name'] : __( 'An AI assistant', 'thinkrank' );
809 $action_url = Mcp_OAuth::authorize_url();
810 $nonce = wp_create_nonce( 'thinkrank_oauth_consent' );
811 $user = wp_get_current_user();
812
813 // Preserve every OAuth param so the POST re-validates identically.
814 $hidden = '';
815 foreach ( [ 'client_id', 'redirect_uri', 'code_challenge', 'scope', 'state' ] as $k ) {
816 $val = 'scope' === $k ? $req['scope'] : ( $req[ $k ] ?? '' );
817 $hidden .= sprintf( '<input type="hidden" name="%s" value="%s" />', esc_attr( $k ), esc_attr( (string) $val ) );
818 }
819 // code_challenge_method + response_type are re-asserted for validation.
820 $hidden .= '<input type="hidden" name="code_challenge_method" value="S256" />';
821 $hidden .= '<input type="hidden" name="response_type" value="code" />';
822
823 status_header( 200 );
824 header( 'Content-Type: text/html; charset=utf-8' );
825 header( 'Cache-Control: no-store' );
826
827 $host = (string) wp_parse_url( home_url(), PHP_URL_HOST );
828 $logo = '<svg width="36" height="36" viewBox="0 0 29 29" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">'
829 . '<g clip-path="url(#tr_clip0)">'
830 . '<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)"/>'
831 . '<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)"/>'
832 . '<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)"/>'
833 . '<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"/>'
834 . '<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)"/>'
835 . '<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)"/>'
836 . '</g>'
837 . '<rect x="0.353516" y="0.353516" width="28" height="28" rx="7.76216" stroke="#B6CBFF" stroke-width="0.707321"/>'
838 . '<defs>'
839 . '<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>'
840 . '<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>'
841 . '<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>'
842 . '<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>'
843 . '<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>'
844 . '<clipPath id="tr_clip0"><rect x="0.353516" y="0.353516" width="28" height="28" rx="7.76216" fill="white"/></clipPath>'
845 . '</defs></svg>';
846 $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>';
847
848 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>';
849 echo '<style>'
850 . ':root{color-scheme:dark}*{box-sizing:border-box}'
851 . '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}'
852 . '.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)}'
853 . '.brand{display:flex;align-items:center;gap:10px;margin-bottom:22px}'
854 . '.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)}'
855 . '.brand b{font-size:14px;font-weight:700;letter-spacing:.02em}'
856 . 'h1{font-size:20px;font-weight:700;margin:0 0 6px}'
857 . '.sub{color:#9aa6be;font-size:13.5px;margin:0 0 22px}.sub strong{color:#e7ecf3;font-weight:600}'
858 . '.rows{border:1px solid #263149;border-radius:14px;overflow:hidden;margin-bottom:16px}'
859 . '.row{display:flex;justify-content:space-between;align-items:center;gap:16px;padding:13px 16px;font-size:13.5px}'
860 . '.row+.row,.access{border-top:1px solid #263149}'
861 . '.row .k{color:#9aa6be}.row .v{font-weight:600;text-align:right;word-break:break-word}'
862 . '.access{padding:14px 16px;background:rgba(99,102,241,.07)}'
863 . '.access .k{color:#9aa6be;font-size:13px;margin-bottom:8px}'
864 . '.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)}'
865 . '.badge.ro{background:rgba(245,158,11,.15);color:#fcd9a1;border-color:rgba(245,158,11,.4)}'
866 . '.access .d{color:#c3ccdd;font-size:13px;margin-top:8px}'
867 . '.note{display:flex;align-items:center;gap:7px;color:#7f8aa3;font-size:12px;margin:0 0 20px}'
868 . '.actions{display:flex;gap:12px}'
869 . '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)}'
870 . '.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)}'
871 . '.deny{background:transparent;color:#aeb8cc;border:1px solid #33405c}.deny:hover{background:rgba(255,255,255,.04)}'
872 . '</style></head><body><div class="card">';
873
874 echo '<div class="brand"><span class="logo">' . $logo . '</span><b>ThinkRank</b></div>'; // phpcs:ignore WordPress.Security.EscapeOutput -- static markup.
875 echo '<h1>' . esc_html__( 'Connect to ThinkRank', 'thinkrank' ) . '</h1>';
876 $sub = sprintf(
877 /* translators: %s: AI client name, already escaped and wrapped in <strong>. */
878 esc_html__( '%s wants to manage SEO on this site.', 'thinkrank' ),
879 '<strong>' . esc_html( $client ) . '</strong>'
880 );
881 echo '<p class="sub">' . $sub . '</p>'; // phpcs:ignore WordPress.Security.EscapeOutput -- static translation; client name esc_html'd.
882
883 echo '<div class="rows">';
884 echo '<div class="row"><span class="k">' . esc_html__( 'Site', 'thinkrank' ) . '</span><span class="v">' . esc_html( $host ) . '</span></div>';
885 echo '<div class="row"><span class="k">' . esc_html__( 'Signed in as', 'thinkrank' ) . '</span><span class="v">' . esc_html( $user->user_login ) . '</span></div>';
886 echo '<div class="access"><div class="k">' . esc_html__( 'Access', 'thinkrank' ) . '</div>';
887 echo '<span class="badge ' . ( $read_only ? 'ro' : '' ) . '">' . esc_html( $access_label ) . '</span>';
888 echo '<div class="d">' . esc_html( $access_desc ) . '</div></div>';
889 echo '</div>';
890
891 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.
892
893 echo '<form method="post" action="' . esc_url( $action_url ) . '">';
894 echo $hidden; // phpcs:ignore WordPress.Security.EscapeOutput -- built from esc_attr() above.
895 echo '<input type="hidden" name="_thinkrank_oauth_nonce" value="' . esc_attr( $nonce ) . '" />';
896 echo '<div class="actions">';
897 echo '<button class="deny" name="deny" value="1">' . esc_html__( 'Deny', 'thinkrank' ) . '</button>';
898 echo '<button class="approve" name="approve" value="1">' . esc_html__( 'Approve', 'thinkrank' ) . '</button>';
899 echo '</div></form></div></body></html>';
900 exit;
901 }
902
903 /**
904 * Render a standalone OAuth error page (no redirect).
905 *
906 * @param string $message Error message.
907 * @return void
908 */
909 private function emit_oauth_error_page( string $message ): void {
910 status_header( 400 );
911 header( 'Content-Type: text/html; charset=utf-8' );
912 header( 'Cache-Control: no-store' );
913 echo '<!doctype html><html><head><meta charset="utf-8"><title>' . esc_html__( 'Authorization error', 'thinkrank' ) . '</title>';
914 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}'
915 . '.card{background:#1e293b;border:1px solid #334155;border-radius:16px;max-width:440px;padding:32px;text-align:center}</style></head><body>';
916 echo '<div class="card"><h1>' . esc_html__( 'Could not authorize', 'thinkrank' ) . '</h1><p>' . esc_html( $message ) . '</p></div></body></html>';
917 exit;
918 }
919
920 // -- Helpers --
921
922 /**
923 * Read an inbound HTTP header from $_SERVER (for the pretty path).
924 *
925 * @param string $name Header name.
926 * @return string|null
927 */
928 private static function server_header( string $name ): ?string {
929 $key = 'HTTP_' . strtoupper( str_replace( '-', '_', $name ) );
930 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- token compared constant-time downstream; raw header needed verbatim.
931 return isset( $_SERVER[ $key ] ) ? wp_unslash( $_SERVER[ $key ] ) : null;
932 }
933
934 /**
935 * Emit a WP_REST_Response as a JSON HTTP response and stop.
936 *
937 * @param \WP_REST_Response $response Response to emit.
938 * @return void
939 */
940 private function emit_json( \WP_REST_Response $response ): void {
941 status_header( $response->get_status() );
942 // MCP Streamable HTTP: advertise the protocol version we speak so a
943 // strict client can pin it. We answer JSON (a spec-permitted response
944 // type); we never open an SSE stream, so no session header is needed.
945 header( 'MCP-Protocol-Version: ' . Mcp_Server::PROTOCOL_VERSION );
946 // Forward any headers the handler set (notably WWW-Authenticate on a
947 // 401, which drives the OAuth discovery flow).
948 foreach ( $response->get_headers() as $name => $value ) {
949 // Re-assert the status on every header: PHP special-cases
950 // WWW-Authenticate and forces a 401 when no status is given,
951 // which would silently mask the 429 lockout response.
952 header( $name . ': ' . $value, true, $response->get_status() );
953 }
954 $data = $response->get_data();
955 if ( null !== $data ) {
956 header( 'Content-Type: application/json; charset=utf-8' );
957 echo wp_json_encode( $data );
958 }
959 exit;
960 }
961 }
962