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

1,432 lines 61.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * MCP manager — the site side of ThinkRank's MCP integration.
4 *
5 * The plugin speaks the MCP protocol DIRECTLY at this site's own URL. The
6 * user pastes their own site's MCP endpoint + connection token into their AI
7 * client — or, for OAuth-capable clients (claude.ai remote connectors), just
8 * the URL:
9 *
10 * https://thissite.com/thinkrank/mcp (pretty, via rewrite)
11 * https://thissite.com/wp-json/thinkrank/v1/mcp (always-on fallback)
12 *
13 * The MCP JSON-RPC handling lives in Mcp_Server; the tool surface is the
14 * abilities registry (Mcp_Tools). Auth is the per-site connection token
15 * (Mcp_Pairing) or an OAuth 2.1 access token (Mcp_OAuth).
16 *
17 * Admin-only management routes (manage_options) drive the MCP page:
18 * /mcp/connection, /mcp/connect, /mcp/rotate, /mcp/disconnect.
19 *
20 * The token-only MCP + OAuth routes are exempted from Role_Manager's
21 * namespace-wide capability gate (they authenticate inside the handler); the
22 * exemption lives in Role_Manager::gate_rest().
23 *
24 * A single admin toggle (`enable_mcp`) is the master switch: when off, the
25 * MCP endpoint, discovery documents, and OAuth endpoints all refuse to serve.
26 *
27 * @package ThinkRank\Mcp
28 */
29
30 declare(strict_types=1);
31
32 namespace ThinkRank\Mcp;
33
34 use ThinkRank\Core\Settings;
35
36 if ( ! defined( 'ABSPATH' ) ) {
37 exit; // Exit if accessed directly.
38 }
39
40 /**
41 * Registers the MCP endpoint, OAuth discovery/authorize/token surface, and
42 * the admin management routes.
43 */
44 final class Mcp_Manager {
45
46 /**
47 * REST namespace shared with the rest of the plugin.
48 */
49 private const NS = 'thinkrank/v1';
50
51 /**
52 * Query var flagging a pretty /thinkrank/mcp request.
53 */
54 private const QUERY_VAR = 'thinkrank_mcp';
55
56 /**
57 * Whether this request is an MCP call being served.
58 *
59 * The pretty /thinkrank/mcp route is dispatched from `parse_request`, so it
60 * is neither `is_admin()` nor `REST_REQUEST`. Anything deciding whether a
61 * request may take on deferred work needs to be able to see it (#764).
62 *
63 * @since 2.10.0
64 * @var bool
65 */
66 private static $serving_request = false;
67
68 /**
69 * Whether an MCP call is being served on this request.
70 *
71 * Pair with `is_user_logged_in()` to mean "an authenticated MCP call":
72 * Mcp_Server sets the current user only once a credential has validated, so
73 * an unauthenticated POST never looks like one.
74 *
75 * @since 2.10.0
76 * @return bool
77 */
78 public static function is_serving_request(): bool {
79 return self::$serving_request;
80 }
81
82 /**
83 * Query var carrying the token when embedded in the URL path.
84 */
85 private const TOKEN_QUERY_VAR = 'thinkrank_mcp_token';
86
87 /**
88 * Query var flagging a /.well-known/ OAuth discovery request.
89 */
90 private const WELLKNOWN_QUERY_VAR = 'thinkrank_mcp_wellknown';
91
92 /**
93 * Query var carrying the resource path a root-form discovery request asked
94 * about, so the handler can tell whether that resource is ours (#516).
95 */
96 private const WELLKNOWN_RESOURCE_QUERY_VAR = 'thinkrank_mcp_wellknown_resource';
97
98 /**
99 * Query var flagging the browser-facing OAuth authorize page. This is
100 * served OUTSIDE the REST API on purpose: a REST route only honors cookie
101 * auth when a REST nonce accompanies it, but a browser arriving from
102 * wp-login carries the cookie with NO nonce — so is_user_logged_in()
103 * would be false there and the consent screen would loop back to login
104 * forever. A normal front-end URL (rewrite + parse_request) sees standard
105 * cookie auth, so the logged-in admin check works.
106 */
107 private const AUTHORIZE_QUERY_VAR = 'thinkrank_mcp_authorize';
108
109 /**
110 * Initialize (called by the plugin's component container).
111 *
112 * @return void
113 */
114 public function init(): void {
115 add_action( 'rest_api_init', [ $this, 'register_rest' ] );
116
117 // Published /.well-known/ files are served ahead of WordPress, so a
118 // site URL change leaves them advertising the old domain's issuer with
119 // nothing to correct them. Registered unconditionally: a stale
120 // document is harmful whether or not MCP is currently enabled (#486).
121 Mcp_Static_Discovery::init();
122
123 // Pretty per-site endpoint: /thinkrank/mcp → MCP JSON-RPC handler.
124 add_action( 'init', [ $this, 'add_rewrite' ] );
125 add_filter( 'query_vars', [ $this, 'register_query_var' ] );
126 add_action( 'parse_request', [ $this, 'maybe_handle_pretty_endpoint' ] );
127
128 // The one broken state the server can't see from inside a request:
129 // MCP enabled but the bundled Abilities runtime absent. Everything
130 // else still works — OAuth discovers, tokens mint, clients connect —
131 // and tools/list is an empty array served as success. Three layers
132 // each "no-op gracefully" (the is_readable() require, the registrar,
133 // the tool registry) and composed they manufacture a connector that
134 // connects and offers nothing, with no signal anywhere. Say it loudly
135 // where an admin will look.
136 add_action( 'admin_notices', [ $this, 'warn_when_runtime_missing' ] );
137 }
138
139 /**
140 * Admin notice when MCP is enabled but the Abilities runtime is missing.
141 *
142 * That combination almost always means an incomplete package — a source
143 * archive or a zip built without `dependencies/vendor/` (the bundled
144 * Abilities API + MCP adapter). Shown to admins on every screen: the fix
145 * is reinstalling the plugin, and a user mid-support-ticket needs to see
146 * it without knowing which screen to visit.
147 *
148 * @return void
149 */
150 public function warn_when_runtime_missing(): void {
151 if ( ! self::is_enabled() || function_exists( 'wp_register_ability' ) ) {
152 return;
153 }
154 if ( ! current_user_can( 'manage_options' ) ) {
155 return;
156 }
157 printf(
158 '<div class="notice notice-error"><p><strong>%s</strong> %s</p></div>',
159 esc_html__( 'ThinkRank MCP: AI assistants will connect but see no tools.', 'thinkrank' ),
160 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' )
161 );
162 }
163
164 /**
165 * Whether the MCP integration is enabled via the admin setting.
166 *
167 * @return bool
168 */
169 public static function is_enabled(): bool {
170 return (bool) Settings::instance()->get( 'enable_mcp', false );
171 }
172
173 // -- Pretty endpoint: /thinkrank/mcp --
174
175 /**
176 * Register rewrite rules for the MCP endpoint, OAuth discovery documents,
177 * and the browser-facing authorize page.
178 *
179 * @return void
180 */
181 public function add_rewrite(): void {
182 $rules = self::rewrite_rules();
183
184 foreach ( $rules as $regex => $query ) {
185 add_rewrite_rule( $regex, $query, 'top' );
186 }
187
188 // Self-heal: flush once if ANY of our rules is missing from the stored
189 // rewrite table, so the endpoints work without a manual permalink
190 // re-save (and newly added rules trigger a re-flush on upgrade).
191 //
192 // Checked against the same array that was just registered, never
193 // against a second hand-maintained list. The two drifted apart once
194 // already: #775's first pass changed the discovery regex and left the
195 // superseded one in the list, so a rule that is never registered was
196 // permanently "missing" and every front-end request rebuilt the whole
197 // rewrite table. Measured at 4 regenerations across 3 page loads
198 // against 0 before the change — silent, because a rebuilt table is
199 // still a correct one.
200 //
201 // It also has to notice the opposite: a rule of ours that is stored
202 // but no longer wanted. The bare discovery rule is registered only
203 // while thinkrank_mcp_serve_root_discovery is on, and "missing" alone
204 // never fires when it is switched off again, because every rule still
205 // registered is present. The stale rule then kept claiming the bare
206 // URL from other MCP plugins (#775) until someone re-saved
207 // permalinks. The unwanted set is derived from the same table built
208 // with every optional rule on, not listed, so it cannot drift either.
209 // A flush rebuilds the table from what this request registered, so
210 // the stale rule is gone afterwards and the check settles instead of
211 // flushing on every request.
212 $stored = get_option( 'rewrite_rules' );
213 if ( is_array( $stored ) ) {
214 $unwanted = array_diff_key( self::rewrite_rules( true ), $rules );
215 $stale = array_intersect_key( $unwanted, $stored );
216 $missing = array_diff_key( $rules, $stored );
217
218 if ( ! empty( $missing ) || ! empty( $stale ) ) {
219 flush_rewrite_rules( false );
220 }
221 }
222 }
223
224 /**
225 * Every rewrite rule this plugin owns, as regex => query string.
226 *
227 * Single source of truth for registration AND for the self-heal check, so
228 * the two cannot describe different sets of rules. All of them register at
229 * `'top'`; a `'bottom'` rule would never be reached, because core's own
230 * pagename rule matches almost any path ahead of it.
231 *
232 * @since 2.10.0
233 *
234 * @param bool|null $root_discovery Include the opt-in bare discovery rule.
235 * Null (the default) follows
236 * {@see self::serves_root_discovery()};
237 * true is how add_rewrite() learns which
238 * optional rules exist, to retire one
239 * that was switched off.
240 * @return array<string,string> Regex => query string.
241 */
242 private static function rewrite_rules( ?bool $root_discovery = null ): array {
243 $endpoint = preg_quote( trim( Mcp_Pairing::SITE_ENDPOINT_PATH, '/' ), '/' );
244
245 $rules = [
246 // Token-in-URL form: /thinkrank/mcp/<token> — a single string the
247 // user pastes into their AI client (no separate token field). The
248 // bare /thinkrank/mcp still works with a Bearer token.
249 '^thinkrank/mcp/([a-f0-9]{64})/?$' =>
250 'index.php?' . self::QUERY_VAR . '=1&' . self::TOKEN_QUERY_VAR . '=$matches[1]',
251 '^thinkrank/mcp/?$' => 'index.php?' . self::QUERY_VAR . '=1',
252
253 // OAuth discovery documents. RFC 9728 §3.1 / RFC 8414 §3.1 place
254 // the `.well-known` segment BEFORE the resource path, so our
255 // resource at /thinkrank/mcp is discovered at:
256 // /.well-known/oauth-protected-resource/thinkrank/mcp
257 // /.well-known/oauth-authorization-server/thinkrank/mcp
258 // The OAuth issuer is the path-based identifier
259 // home_url('/thinkrank/mcp') (see Mcp_OAuth::issuer), so
260 // spec-compliant clients derive exactly these URLs.
261 '^\.well-known/oauth-(protected-resource|authorization-server)/thinkrank/mcp/?$' =>
262 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]',
263
264 // The same form with a site prefix in front of our endpoint path,
265 // which is how subdirectory multisite discovers /ca/thinkrank/mcp
266 // (#516). The trailing path is CAPTURED rather than discarded and
267 // the pattern only accepts a path ENDING in our own endpoint: an
268 // earlier version matched `(/.*)?` — anything at all — and so
269 // claimed every neighbouring MCP plugin's RFC 9728 discovery URL
270 // alongside our own (#775).
271 //
272 // Declining inside the handler does not undo that, and this is the
273 // part worth being precise about: rewrite matching happens once, in
274 // WP::parse_request(), before the `parse_request` action our
275 // handler runs on. By then this rule has already won and the owning
276 // plugin's rule has not matched, so its query var is never set and
277 // its handler never fires. Returning instead of exiting would leave
278 // the request to die in the main query — a 404 either way, exactly
279 // as broken for the neighbour as serving them our document was. The
280 // rule itself has to stop matching, so normal rewrite resolution
281 // reaches the plugin that owns the URL.
282 //
283 // The handler still validates what this does match: a prefix that
284 // is not a real site on this network is declined there.
285 '^\.well-known/oauth-(protected-resource|authorization-server)((?:/.*)?/' . $endpoint . ')/?$' =>
286 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]&' . self::WELLKNOWN_RESOURCE_QUERY_VAR . '=$matches[2]',
287
288 // Suffix form: <issuer>/.well-known/... . RFC 8414 specifies the
289 // path-INSERT form above, but the older OpenID Connect Discovery
290 // convention appends instead, and clients built on an OIDC library
291 // try that shape first (sometimes only that shape). Both of these
292 // live under our own endpoint path, so neither can collide with
293 // another plugin's discovery URLs.
294 '^thinkrank/mcp/\.well-known/oauth-(protected-resource|authorization-server)/?$' =>
295 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]',
296 '^thinkrank/mcp/\.well-known/openid-configuration/?$' =>
297 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=authorization-server',
298
299 // Browser-facing OAuth consent page — served OUTSIDE REST so cookie
300 // auth (is_user_logged_in) works after the wp-login round-trip.
301 '^thinkrank/authorize/?$' => 'index.php?' . self::AUTHORIZE_QUERY_VAR . '=1',
302 ];
303
304 if ( $root_discovery ?? self::serves_root_discovery() ) {
305 $rules['^\.well-known/oauth-(protected-resource|authorization-server)/?$'] =
306 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]';
307 }
308
309 return $rules;
310 }
311
312 /**
313 * Whether to answer the BARE `/.well-known/oauth-*` URLs, with no resource
314 * path after the document name. Off by default.
315 *
316 * That URL is not ours to answer, in two separate senses.
317 *
318 * It is not ours by the specs. RFC 9728 §3.1 builds a resource's metadata
319 * URL by inserting `.well-known` before the resource's path, so the bare
320 * form is the metadata URL for the resource `https://example.com` — the
321 * site root. Ours is `https://example.com/thinkrank/mcp`. §3.3 requires the
322 * `resource` in the document to equal the identifier it was fetched for, so
323 * answering there returns a document that contradicts its own URL. RFC 8414
324 * §3.3 says the same of `issuer`. A client that validates is entitled to
325 * reject it; the only clients it ever helped were those that do not.
326 *
327 * And it is not ours on a site with a second MCP plugin. Registered at
328 * `'top'`, our rule did not merely enter the ordering race for that URL, it
329 * went to the front of it — so a neighbour's client reading the bare form
330 * got OUR resource identifier while connecting somewhere else, which is the
331 * same RFC 9728 mismatch #775 was filed about, reached by the other URL.
332 * Because the winner depends on registration and flush order, it also
333 * reproduced intermittently: the same site could behave differently after
334 * an unrelated flush.
335 *
336 * Nothing of ours needs it. The 401 challenge advertises
337 * {@see Mcp_OAuth::resource_metadata_url()}, a route in our own REST
338 * namespace that no other plugin can claim; the path-insert and
339 * OIDC-suffix forms above cover spec-compliant and OIDC-library clients;
340 * {@see Mcp_Self_Test} probes none of the bare URLs; and
341 * {@see Mcp_Static_Discovery} publishes only the path-suffixed files.
342 *
343 * What it did cover is a client that derives the metadata URL from the host
344 * alone, dropping the path. On a site where ThinkRank is the only MCP
345 * plugin that client worked, and this filter is how such a site keeps it
346 * working. It is opt-in because switching it on is a claim over a shared
347 * URL, and only the site owner knows whether anything else wants it.
348 *
349 * @since 2.10.0
350 *
351 * @return bool
352 */
353 public static function serves_root_discovery(): bool {
354 /**
355 * Filter whether ThinkRank answers the bare `/.well-known/oauth-*`
356 * discovery URLs, which carry no resource path.
357 *
358 * Off by default: the URL identifies the site root rather than our MCP
359 * endpoint, and claiming it breaks any other MCP plugin on the site.
360 * Switch it on only where ThinkRank is the sole MCP provider and a
361 * client derives the metadata URL from the host without the path.
362 *
363 * @since 2.10.0
364 *
365 * @param bool $serve Whether to register the bare discovery rules.
366 */
367 return (bool) apply_filters( 'thinkrank_mcp_serve_root_discovery', false );
368 }
369
370 /**
371 * Register our query vars.
372 *
373 * @param string[] $vars Registered query vars.
374 * @return string[]
375 */
376 public function register_query_var( array $vars ): array {
377 $vars[] = self::QUERY_VAR;
378 $vars[] = self::TOKEN_QUERY_VAR;
379 $vars[] = self::WELLKNOWN_QUERY_VAR;
380 $vars[] = self::WELLKNOWN_RESOURCE_QUERY_VAR;
381 $vars[] = self::AUTHORIZE_QUERY_VAR;
382 return $vars;
383 }
384
385 /**
386 * Resolve a root-form discovery request to the site that owns the resource.
387 *
388 * The path-suffixed and issuer-suffixed rules are already pinned to
389 * `thinkrank/mcp`, so only the broad root-form rule can arrive here naming
390 * something else. Three outcomes:
391 *
392 * - `0` — serve from the current site. That covers the bare form
393 * (`/.well-known/oauth-authorization-server`, the whole reason the
394 * fallback rule exists) and this site's own endpoint path.
395 * - a blog id — a subdirectory multisite request for another site's
396 * resource. On subdirectory multisite everything under the network root
397 * is served by the MAIN site, so a client discovering
398 * `/ca/thinkrank/mcp` lands here; the document has to be built from the
399 * `/ca/` site or every endpoint in it loses the prefix.
400 * - `null` — not ours. Another plugin's resource, an unknown site path, or
401 * a path that merely contains ours.
402 *
403 * @since 2.9.0
404 *
405 * @param \WP $wp The WP request object.
406 * @return int|null Blog id to serve from, 0 for the current site, null to decline.
407 */
408 private static function resolve_wellknown_target( $wp ): ?int {
409 $requested = isset( $wp->query_vars[ self::WELLKNOWN_RESOURCE_QUERY_VAR ] )
410 ? trim( (string) $wp->query_vars[ self::WELLKNOWN_RESOURCE_QUERY_VAR ], '/' )
411 : '';
412
413 $ours = trim( Mcp_Pairing::SITE_ENDPOINT_PATH, '/' );
414
415 if ( '' === $requested || $requested === $ours ) {
416 return 0;
417 }
418
419 if ( ! is_multisite() || ! function_exists( 'get_site_by_path' ) ) {
420 return null;
421 }
422
423 // Whatever precedes our endpoint path is the candidate site path:
424 // `ca/thinkrank/mcp` -> `/ca/`. Matching the tail as a whole path
425 // segment, not a substring, so `thinkrank/mcp-other` cannot qualify.
426 $candidate = '/' . $requested;
427 $suffix = '/' . $ours;
428
429 if ( substr( $candidate, - strlen( $suffix ) ) !== $suffix ) {
430 return null;
431 }
432
433 $site_path = substr( $candidate, 0, - strlen( $ours ) );
434 $domain = self::request_domain();
435
436 if ( '' === $domain || '' === $site_path ) {
437 return null;
438 }
439
440 $site = get_site_by_path( $domain, $site_path );
441
442 if ( ! $site ) {
443 return null;
444 }
445
446 // get_site_by_path() walks the path segments and falls back to the
447 // network's root site when none match, so an unknown prefix comes back
448 // as the MAIN site rather than as nothing. Taking that at face value
449 // reinstates the exact bug for every path that is not a real subsite:
450 // /nope/thinkrank/mcp would be answered with the main site's document.
451 // Require the match to be the path that was actually asked for.
452 if ( untrailingslashit( (string) $site->path ) !== untrailingslashit( $site_path ) ) {
453 return null;
454 }
455
456 // get_sites() applies no status filter, so a site the network has taken
457 // out of service resolves like any other. Advertising an authorization
458 // server for one would point a client at an endpoint that cannot serve
459 // it. `public` is deliberately NOT checked: on multisite that flag is
460 // search-engine visibility, not availability, and a site can reasonably
461 // be hidden from search while still running MCP.
462 if ( ! empty( $site->archived ) || ! empty( $site->deleted ) || ! empty( $site->spam ) ) {
463 return null;
464 }
465
466 return (int) $site->blog_id === get_current_blog_id() ? 0 : (int) $site->blog_id;
467 }
468
469 /**
470 * Host for a `get_site_by_path()` lookup.
471 *
472 * Mirrors what WordPress itself stores in `wp_blogs`: core strips only the
473 * default ports when it resolves the current site, so a development network
474 * running on a non-default port keeps it, and stripping every port here
475 * would fail to match those rows.
476 *
477 * @since 2.9.0
478 *
479 * @return string Host, or an empty string when the request carries none.
480 */
481 private static function request_domain(): string {
482 if ( empty( $_SERVER['HTTP_HOST'] ) ) {
483 return '';
484 }
485
486 $host = strtolower( sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) );
487
488 if ( ':80' === substr( $host, -3 ) ) {
489 return substr( $host, 0, -3 );
490 }
491
492 if ( ':443' === substr( $host, -4 ) ) {
493 return substr( $host, 0, -4 );
494 }
495
496 return $host;
497 }
498
499 /**
500 * Build a discovery document from the site that owns the resource.
501 *
502 * The switch is what makes the returned endpoints carry the subsite prefix,
503 * since every URL in the document comes from `home_url()` / `rest_url()`.
504 * Settings memoizes per setting name with no notion of which site it read
505 * from; it clears itself on `switch_blog` (see Settings::init), which is
506 * what stops the MCP-enabled check below answering for the previous site.
507 *
508 * Returns null when the owning site has MCP turned off: a site that is not
509 * serving MCP must not advertise an authorization server for it.
510 *
511 * @since 2.9.0
512 *
513 * @param int $blog_id Blog to build from, 0 for the current site.
514 * @param string $doc Document type from the rewrite.
515 * @return array<string,mixed>|null
516 */
517 private static function discovery_document_for( int $blog_id, string $doc ): ?array {
518 $switched = false;
519
520 if ( $blog_id > 0 ) {
521 switch_to_blog( $blog_id );
522 $switched = true;
523 }
524
525 $data = null;
526
527 try {
528 if ( self::is_enabled() ) {
529 $data = 'authorization-server' === $doc
530 ? Mcp_OAuth::authorization_server_metadata()
531 : Mcp_OAuth::protected_resource_metadata();
532 }
533 } finally {
534 // A throw between the switch and the restore would leave the rest
535 // of the request, including shutdown hooks, running against the
536 // wrong site. Cheap to make impossible.
537 if ( $switched ) {
538 restore_current_blog();
539 }
540 }
541
542 return $data;
543 }
544
545 /**
546 * Serve the MCP endpoint on the pretty path. Runs on parse_request so it
547 * fires before the main query, and short-circuits WP entirely.
548 *
549 * @param \WP $wp The WP request object.
550 * @return void
551 */
552 public function maybe_handle_pretty_endpoint( $wp ): void {
553 // OAuth discovery documents (served at the site root).
554 if ( ! empty( $wp->query_vars[ self::WELLKNOWN_QUERY_VAR ] ) ) {
555 // The path after the document type names the RESOURCE being
556 // discovered, and it used to be discarded (#516). Resolve it to
557 // the site that actually owns it, which on subdirectory multisite
558 // is how /ca/thinkrank/mcp stops being answered by the main site
559 // with endpoints that have no /ca/ in them.
560 $target = self::resolve_wellknown_target( $wp );
561
562 if ( null === $target ) {
563 status_header( 404 );
564 exit;
565 }
566
567 $data = self::discovery_document_for(
568 $target,
569 (string) $wp->query_vars[ self::WELLKNOWN_QUERY_VAR ]
570 );
571
572 if ( null === $data ) {
573 status_header( 404 );
574 exit;
575 }
576
577 status_header( 200 );
578 header( 'Content-Type: application/json; charset=utf-8' );
579 // Discovery metadata is public + cacheable.
580 header( 'Cache-Control: public, max-age=3600' );
581 echo wp_json_encode( $data );
582 exit;
583 }
584
585 // Browser-facing OAuth consent page (cookie auth applies here).
586 if ( ! empty( $wp->query_vars[ self::AUTHORIZE_QUERY_VAR ] ) ) {
587 if ( ! self::is_enabled() ) {
588 status_header( 404 );
589 exit;
590 }
591 $this->handle_authorize_page();
592 return;
593 }
594
595 if ( empty( $wp->query_vars[ self::QUERY_VAR ] ) ) {
596 return;
597 }
598
599 // Mark the request so work that only front-end traffic must not pay for
600 // can tell this apart from an anonymous page view. An MCP call is a
601 // deliberate admin-equivalent action, but it arrives on the pretty
602 // /thinkrank/mcp route, so it is neither is_admin() nor REST_REQUEST
603 // and was invisible to those checks (#764).
604 self::$serving_request = true;
605
606 $request = new \WP_REST_Request( 'POST', '/' . self::NS . '/mcp' );
607 $request->set_header( 'content-type', 'application/json' );
608 // Carry the auth header + raw body from the live PHP request.
609 $auth = self::server_header( 'authorization' );
610 if ( null !== $auth ) {
611 $request->set_header( 'authorization', $auth );
612 }
613 // Token embedded in the URL path (/thinkrank/mcp/<token>) — surface it
614 // as a Bearer header so Mcp_Server validates it the same way. A real
615 // Authorization header (if also sent) takes precedence.
616 $path_token = isset( $wp->query_vars[ self::TOKEN_QUERY_VAR ] )
617 ? (string) $wp->query_vars[ self::TOKEN_QUERY_VAR ]
618 : '';
619 if ( '' !== $path_token && '' === (string) $request->get_header( 'authorization' ) ) {
620 $request->set_header( 'authorization', 'Bearer ' . $path_token );
621 }
622 $request->set_body( (string) file_get_contents( 'php://input' ) );
623
624 $response = Mcp_Server::handle( $request );
625 $this->emit_json( $response );
626 }
627
628 // -- REST registration --
629
630 /**
631 * Register the REST routes: the MCP JSON-RPC fallback, the admin
632 * management routes, and the OAuth registration/token endpoints.
633 *
634 * @return void
635 */
636 public function register_rest(): void {
637 // --- MCP JSON-RPC endpoint (fallback path via wp-json) -----------
638 // permission_callback is __return_true because Mcp_Server does its own
639 // token auth and must reply with a JSON-RPC 401 + WWW-Authenticate,
640 // not a bare WP permission failure.
641 register_rest_route(
642 self::NS,
643 '/mcp',
644 [
645 'methods' => 'POST',
646 'callback' => [ $this, 'rest_mcp' ],
647 'permission_callback' => '__return_true',
648 ]
649 );
650
651 // --- Admin-only management routes (the MCP page) ------------------
652 register_rest_route(
653 self::NS,
654 '/mcp/connection',
655 [
656 'methods' => 'GET',
657 'callback' => [ $this, 'rest_connection' ],
658 'permission_callback' => [ $this, 'admin_permission' ],
659 ]
660 );
661 register_rest_route(
662 self::NS,
663 '/mcp/connect',
664 [
665 'methods' => 'POST',
666 'callback' => [ $this, 'rest_connect' ],
667 'permission_callback' => [ $this, 'admin_permission' ],
668 'args' => [
669 'read_only' => [
670 'type' => 'boolean',
671 'required' => false,
672 'default' => false,
673 'description' => 'Grant read-only access (no SEO metadata or settings changes).',
674 ],
675 ],
676 ]
677 );
678 register_rest_route(
679 self::NS,
680 '/mcp/rotate',
681 [
682 'methods' => 'POST',
683 'callback' => [ $this, 'rest_rotate' ],
684 'permission_callback' => [ $this, 'admin_permission' ],
685 'args' => [
686 'read_only' => [
687 'type' => 'boolean',
688 'required' => false,
689 'description' => 'Optionally set read-only on the new token; omit to keep current scopes.',
690 ],
691 ],
692 ]
693 );
694 register_rest_route(
695 self::NS,
696 '/mcp/disconnect',
697 [
698 'methods' => 'POST',
699 'callback' => [ $this, 'rest_disconnect' ],
700 'permission_callback' => [ $this, 'admin_permission' ],
701 ]
702 );
703
704 // Live round-trip diagnostic for the MCP page (see #189). Admin-only;
705 // exercises the endpoint the way an external client would.
706 register_rest_route(
707 self::NS,
708 '/mcp/self-test',
709 [
710 'methods' => 'POST',
711 'callback' => [ $this, 'rest_self_test' ],
712 'permission_callback' => [ $this, 'admin_permission' ],
713 ]
714 );
715
716 // Connected AI apps (see #244): list the OAuth-connected clients plus a
717 // single combined row for the shared static token, and revoke either.
718 register_rest_route(
719 self::NS,
720 '/mcp/apps',
721 [
722 'methods' => 'GET',
723 'callback' => [ $this, 'rest_apps' ],
724 'permission_callback' => [ $this, 'admin_permission' ],
725 ]
726 );
727 register_rest_route(
728 self::NS,
729 '/mcp/apps/revoke',
730 [
731 'methods' => 'POST',
732 'callback' => [ $this, 'rest_revoke_app' ],
733 'permission_callback' => [ $this, 'admin_permission' ],
734 'args' => [
735 'client_id' => [
736 'type' => 'string',
737 'required' => true,
738 'description' => 'The OAuth client_id to revoke.',
739 ],
740 ],
741 ]
742 );
743
744 // --- OAuth 2.1 authorization server (the "paste a URL only" path) -
745 // Discovery, dynamic client registration, and the token endpoint are
746 // all public (permission enforced inside): a client must reach them
747 // BEFORE it holds any credential.
748 //
749 // The discovery documents are ALSO served here, not only at the
750 // /.well-known/ rewrites: hosts that resolve root /.well-known/ at
751 // their proxy edge (SiteGround) never let those requests reach
752 // WordPress, while /wp-json/ always arrives. The 401 challenge
753 // advertises this route (Mcp_OAuth::resource_metadata_url), so the
754 // flow survives on such hosts.
755 register_rest_route(
756 self::NS,
757 '/mcp/oauth/protected-resource',
758 [
759 'methods' => 'GET',
760 'callback' => [ $this, 'rest_oauth_discovery_resource' ],
761 'permission_callback' => '__return_true',
762 ]
763 );
764 register_rest_route(
765 self::NS,
766 '/mcp/oauth/authorization-server',
767 [
768 'methods' => 'GET',
769 'callback' => [ $this, 'rest_oauth_discovery_server' ],
770 'permission_callback' => '__return_true',
771 ]
772 );
773 register_rest_route(
774 self::NS,
775 '/mcp/oauth/register',
776 [
777 'methods' => 'POST',
778 'callback' => [ $this, 'rest_oauth_register' ],
779 'permission_callback' => '__return_true',
780 ]
781 );
782 // NOTE: /authorize is deliberately NOT a REST route — it is served as
783 // a normal front-end page at /thinkrank/authorize (see
784 // handle_authorize_page) so cookie auth works after wp-login.
785 register_rest_route(
786 self::NS,
787 '/mcp/oauth/token',
788 [
789 'methods' => 'POST',
790 'callback' => [ $this, 'rest_oauth_token' ],
791 'permission_callback' => '__return_true',
792 ]
793 );
794 }
795
796 /**
797 * Capability gate for the admin-only management routes.
798 *
799 * @return bool
800 */
801 public function admin_permission(): bool {
802 return current_user_can( 'manage_options' );
803 }
804
805 // -- Handlers ----------------------------------------------------------
806
807 /**
808 * MCP JSON-RPC over the wp-json fallback path.
809 *
810 * @param \WP_REST_Request $request Incoming request.
811 * @return \WP_REST_Response
812 */
813 public function rest_mcp( \WP_REST_Request $request ): \WP_REST_Response {
814 $response = Mcp_Server::handle( $request );
815 // Advertise the MCP protocol version on the wp-json transport too, so
816 // both endpoints behave identically to a strict Streamable-HTTP client.
817 $response->header( 'MCP-Protocol-Version', Mcp_Server::PROTOCOL_VERSION );
818 return $response;
819 }
820
821 /**
822 * GET /mcp/connection — pairing status for the MCP page.
823 *
824 * @return \WP_REST_Response
825 */
826 public function rest_connection(): \WP_REST_Response {
827 $this->ensure_connected();
828 return rest_ensure_response( Mcp_Pairing::public_status() );
829 }
830
831 /**
832 * Self-heal: whenever the admin views the MCP page with MCP enabled, make
833 * sure a connection token exists. New sites mint on the enable toggle (see
834 * #244), but a site that had MCP on before that behavior shipped would have
835 * no token; minting here — idempotent, admin-gated — keeps the connect
836 * recipes populated without a separate "Generate token" click.
837 *
838 * @return void
839 */
840 private function ensure_connected(): void {
841 if ( self::is_enabled() && ! Mcp_Pairing::is_connected() ) {
842 Mcp_Pairing::connect();
843 }
844 }
845
846 /**
847 * POST /mcp/connect — mint a connection token.
848 *
849 * @param \WP_REST_Request $request Carries optional read_only.
850 * @return \WP_REST_Response
851 */
852 public function rest_connect( \WP_REST_Request $request ): \WP_REST_Response {
853 $read_only = (bool) $request->get_param( 'read_only' );
854 return rest_ensure_response( Mcp_Pairing::connect( $read_only ) );
855 }
856
857 /**
858 * POST /mcp/rotate — mint a fresh token, invalidating the old one.
859 *
860 * @param \WP_REST_Request $request Carries optional read_only.
861 * @return \WP_REST_Response
862 */
863 public function rest_rotate( \WP_REST_Request $request ): \WP_REST_Response {
864 $read_only = null;
865 if ( null !== $request->get_param( 'read_only' ) ) {
866 $read_only = (bool) $request->get_param( 'read_only' );
867 }
868 return rest_ensure_response( Mcp_Pairing::rotate( $read_only ) );
869 }
870
871 /**
872 * POST /mcp/disconnect — revoke the connection token + all OAuth grants.
873 *
874 * @return \WP_REST_Response
875 */
876 public function rest_disconnect(): \WP_REST_Response {
877 return rest_ensure_response( Mcp_Pairing::disconnect() );
878 }
879
880 /**
881 * POST /mcp/self-test — run the live round-trip diagnostic (see #189).
882 *
883 * @return \WP_REST_Response
884 */
885 public function rest_self_test(): \WP_REST_Response {
886 return rest_ensure_response( Mcp_Self_Test::run() );
887 }
888
889 /**
890 * GET /mcp/apps — the "Connected AI apps" list (see #244).
891 *
892 * @return \WP_REST_Response
893 */
894 public function rest_apps(): \WP_REST_Response {
895 $this->ensure_connected();
896 return rest_ensure_response( $this->apps_payload() );
897 }
898
899 /**
900 * POST /mcp/apps/revoke — cut off a single OAuth-connected app. Returns the
901 * refreshed app list so the UI updates in one round trip. (The shared static
902 * token has no per-client identity, so it is not listed or revoked here — it
903 * is rotated from the connect card via /mcp/rotate.)
904 *
905 * @param \WP_REST_Request $request Carries target + client_id.
906 * @return \WP_REST_Response|\WP_Error
907 */
908 public function rest_revoke_app( \WP_REST_Request $request ) {
909 $client_id = (string) $request->get_param( 'client_id' );
910 if ( '' === $client_id ) {
911 return new \WP_Error(
912 'thinkrank_missing_client_id',
913 __( 'A client_id is required to revoke an OAuth app.', 'thinkrank' ),
914 [ 'status' => 400 ]
915 );
916 }
917 Mcp_OAuth::revoke_client( $client_id );
918
919 return rest_ensure_response( $this->apps_payload() );
920 }
921
922 /**
923 * Build the "Connected AI apps" payload: the OAuth-connected clients, with
924 * the approving admin's display name resolved. Header-based (static-token)
925 * clients share one anonymous secret and so are not represented here.
926 *
927 * @return array<string,mixed>
928 */
929 private function apps_payload(): array {
930 $oauth_apps = [];
931 foreach ( Mcp_OAuth::connected_apps() as $app ) {
932 $user = $app['user_id'] > 0 ? get_userdata( $app['user_id'] ) : false;
933 $oauth_apps[] = [
934 'client_id' => $app['client_id'],
935 'name' => $app['name'],
936 'read_only' => $app['read_only'],
937 'approved_by' => $user ? $user->display_name : __( 'Unknown user', 'thinkrank' ),
938 'connected_at' => $app['connected_at'],
939 'last_used' => $app['last_used'],
940 ];
941 }
942
943 return [
944 'oauth_apps' => $oauth_apps,
945 ];
946 }
947
948 // -- OAuth 2.1 handlers ------------------------------------------------
949
950 /**
951 * GET /mcp/oauth/protected-resource — RFC 9728 metadata via REST.
952 *
953 * @return \WP_REST_Response|\WP_Error
954 */
955 public function rest_oauth_discovery_resource() {
956 return $this->oauth_discovery_response( Mcp_OAuth::protected_resource_metadata() );
957 }
958
959 /**
960 * GET /mcp/oauth/authorization-server — RFC 8414 metadata via REST.
961 *
962 * @return \WP_REST_Response|\WP_Error
963 */
964 public function rest_oauth_discovery_server() {
965 return $this->oauth_discovery_response( Mcp_OAuth::authorization_server_metadata() );
966 }
967
968 /**
969 * Shape one discovery document response: public, cacheable, and 404 when
970 * MCP is off — matching the /.well-known/ rewrites exactly, so a client
971 * sees the same truth regardless of which serving path reached it.
972 *
973 * @param array<string,mixed> $document Discovery metadata.
974 * @return \WP_REST_Response|\WP_Error
975 */
976 private function oauth_discovery_response( array $document ) {
977 if ( ! self::is_enabled() ) {
978 return new \WP_Error( 'thinkrank_mcp_disabled', __( 'MCP is disabled on this site.', 'thinkrank' ), [ 'status' => 404 ] );
979 }
980 $response = new \WP_REST_Response( $document, 200 );
981 $response->header( 'Cache-Control', 'public, max-age=3600' );
982 return $response;
983 }
984
985 /**
986 * POST /mcp/oauth/register — RFC 7591 dynamic client registration.
987 *
988 * @param \WP_REST_Request $request JSON body with redirect_uris.
989 * @return \WP_REST_Response|\WP_Error
990 */
991 public function rest_oauth_register( \WP_REST_Request $request ) {
992 if ( ! self::is_enabled() ) {
993 return new \WP_Error( 'thinkrank_mcp_disabled', __( 'MCP is disabled on this site.', 'thinkrank' ), [ 'status' => 403 ] );
994 }
995 $body = $request->get_json_params();
996 if ( ! is_array( $body ) ) {
997 $body = [];
998 }
999 $result = Mcp_OAuth::register_client( $body );
1000 if ( is_wp_error( $result ) ) {
1001 return $result;
1002 }
1003 return new \WP_REST_Response( $result, 201 );
1004 }
1005
1006 /**
1007 * POST /mcp/oauth/token — exchange a code (or refresh token) for tokens.
1008 *
1009 * @param \WP_REST_Request $request Form-encoded or JSON token request.
1010 * @return \WP_REST_Response
1011 */
1012 public function rest_oauth_token( \WP_REST_Request $request ): \WP_REST_Response {
1013 if ( ! self::is_enabled() ) {
1014 $response = new \WP_REST_Response(
1015 [
1016 'error' => 'invalid_request',
1017 'error_description' => 'MCP is disabled on this site.',
1018 ],
1019 403
1020 );
1021 $response->header( 'Cache-Control', 'no-store' );
1022 return $response;
1023 }
1024
1025 // Token requests are application/x-www-form-urlencoded per OAuth, but
1026 // accept JSON too. get_body_params() covers the form case.
1027 $body = $request->get_body_params();
1028 if ( empty( $body ) ) {
1029 $json = $request->get_json_params();
1030 $body = is_array( $json ) ? $json : [];
1031 }
1032 $body = array_map( 'strval', $body );
1033
1034 $result = Mcp_OAuth::exchange_token( $body );
1035 if ( is_wp_error( $result ) ) {
1036 $data = $result->get_error_data();
1037 $response = new \WP_REST_Response(
1038 [
1039 'error' => isset( $data['error'] ) ? $data['error'] : 'invalid_request',
1040 'error_description' => isset( $data['error_description'] ) ? $data['error_description'] : $result->get_error_message(),
1041 ],
1042 isset( $data['status'] ) ? (int) $data['status'] : 400
1043 );
1044 $response->header( 'Cache-Control', 'no-store' );
1045 return $response;
1046 }
1047 $response = new \WP_REST_Response( $result, 200 );
1048 $response->header( 'Cache-Control', 'no-store' );
1049 $response->header( 'Pragma', 'no-cache' );
1050 return $response;
1051 }
1052
1053 // -- OAuth authorize page ------------------------------------------------
1054
1055 /**
1056 * The browser-facing OAuth authorize page (served at /thinkrank/authorize
1057 * via a rewrite, NOT the REST API). Reads request params from the
1058 * superglobals because this is a normal front-end request where cookie
1059 * auth populates is_user_logged_in().
1060 *
1061 * GET renders the consent screen (requires a logged-in admin; anonymous
1062 * users go to wp-login and return here). POST is the nonce-checked consent
1063 * submission: Approve issues a code and 302s to the client's redirect_uri;
1064 * Deny 302s back with error=access_denied. Always emits its own response
1065 * (HTML page or redirect) and exits.
1066 *
1067 * @return void
1068 */
1069 public function handle_authorize_page(): void {
1070 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- compared against a literal after strtoupper(); nothing is stored or echoed.
1071 $is_post = isset( $_SERVER['REQUEST_METHOD'] ) && 'POST' === strtoupper( (string) wp_unslash( $_SERVER['REQUEST_METHOD'] ) );
1072 // Params come from GET on the consent link and POST on the form submit.
1073 // Nonce is verified below before any POST value is acted on.
1074 // phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- read verbatim by oauth_param(); see its docblock for why, and where each value is validated or escaped instead.
1075 $source = $is_post ? $_POST : $_GET;
1076 // phpcs:enable WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
1077 $params = [];
1078 foreach ( [ 'client_id', 'redirect_uri', 'response_type', 'code_challenge', 'code_challenge_method', 'scope', 'state', 'approve', 'deny', '_thinkrank_oauth_nonce' ] as $k ) {
1079 $params[ $k ] = self::oauth_param( $source, $k );
1080 }
1081
1082 // Validate the OAuth params before touching the session.
1083 $req = Mcp_OAuth::validate_authorize_request( $params );
1084 if ( is_wp_error( $req ) ) {
1085 $data = $req->get_error_data();
1086 $redirectable = is_array( $data ) && ! empty( $data['redirectable'] );
1087 // Only redirect the error back when redirect_uri is verified valid;
1088 // otherwise show a page (never bounce to an unverified URL).
1089 if ( $redirectable && '' !== $params['redirect_uri'] ) {
1090 $this->redirect_error( $params['redirect_uri'], $req->get_error_code(), $req->get_error_message(), $params['state'] );
1091 }
1092 $this->emit_oauth_error_page( $req->get_error_message() );
1093 }
1094
1095 // Require a logged-in admin. Anonymous → wp-login, back to this URL.
1096 if ( ! is_user_logged_in() ) {
1097 $this->redirect_to_login();
1098 }
1099 if ( ! current_user_can( 'manage_options' ) ) {
1100 $this->emit_oauth_error_page(
1101 __( 'You must be an administrator to authorize an AI assistant to manage SEO on this site.', 'thinkrank' )
1102 );
1103 }
1104
1105 // POST = consent form submitted.
1106 if ( $is_post ) {
1107 if ( ! wp_verify_nonce( $params['_thinkrank_oauth_nonce'], 'thinkrank_oauth_consent' ) ) {
1108 $this->emit_oauth_error_page( __( 'Security check failed. Please try connecting again.', 'thinkrank' ) );
1109 }
1110 if ( '' === $params['approve'] ) {
1111 $this->redirect_error( $req['redirect_uri'], 'access_denied', 'The user denied the request.', $req['state'] );
1112 }
1113 $code = Mcp_OAuth::issue_code( $req, get_current_user_id() );
1114 $this->redirect_success( $req['redirect_uri'], $code, $req['state'] );
1115 }
1116
1117 // GET = render the consent screen.
1118 $this->emit_consent_screen( $req );
1119 }
1120
1121 // -- OAuth browser-response helpers ------------------------------------
1122
1123 /**
1124 * The absolute URL of the current authorize request (for login return).
1125 *
1126 * @return string
1127 */
1128 private function current_authorize_url(): string {
1129 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- reconstructing the current URL for a login round-trip; escaped at use.
1130 $uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '';
1131 return home_url( $uri );
1132 }
1133
1134 /**
1135 * Send an anonymous visitor to wp-login, returning to this authorize URL.
1136 *
1137 * @return void
1138 */
1139 private function redirect_to_login(): void {
1140 wp_safe_redirect( wp_login_url( $this->current_authorize_url() ) );
1141 exit;
1142 }
1143
1144 /**
1145 * 302 back to the client with the authorization code (+ state).
1146 *
1147 * @param string $redirect_uri Validated client redirect URI.
1148 * @param string $code Authorization code.
1149 * @param string $state Client state.
1150 * @return void
1151 */
1152 private function redirect_success( string $redirect_uri, string $code, string $state ): void {
1153 $args = [ 'code' => $code ];
1154 if ( '' !== $state ) {
1155 $args['state'] = $state;
1156 }
1157 // Not wp_safe_redirect: redirect_uri is a client-registered off-site
1158 // callback, already validated against the client's registered set.
1159 wp_redirect( add_query_arg( $args, $redirect_uri ) ); // phpcs:ignore WordPress.Security.SafeRedirect -- validated OAuth redirect_uri.
1160 exit;
1161 }
1162
1163 /**
1164 * 302 back to the client with an OAuth error (+ state).
1165 *
1166 * @param string $redirect_uri Validated client redirect URI.
1167 * @param string $error OAuth error code.
1168 * @param string $description Human-readable description.
1169 * @param string $state Client state.
1170 * @return void
1171 */
1172 private function redirect_error( string $redirect_uri, string $error, string $description, string $state ): void {
1173 $args = [
1174 'error' => $error,
1175 'error_description' => $description,
1176 ];
1177 if ( '' !== $state ) {
1178 $args['state'] = $state;
1179 }
1180 wp_redirect( add_query_arg( array_map( 'rawurlencode', $args ), $redirect_uri ) ); // phpcs:ignore WordPress.Security.SafeRedirect -- validated OAuth redirect_uri.
1181 exit;
1182 }
1183
1184 /**
1185 * Render the consent screen. Minimal self-contained HTML (no admin
1186 * chrome — this is a client-facing OAuth page). Approve/Deny post back
1187 * to the same authorize URL with a nonce.
1188 *
1189 * @param array<string,string> $req Validated authorize params.
1190 * @return void
1191 */
1192 private function emit_consent_screen( array $req ): void {
1193 $read_only = Mcp_OAuth::scope_is_read_only( $req['scope'] );
1194 $access_label = $read_only ? __( 'Read-only', 'thinkrank' ) : __( 'Read & write', 'thinkrank' );
1195 $access_desc = $read_only
1196 ? __( 'Review your SEO across posts and site settings metadata, schema, sitemaps, robots, social, and SEO scores. No changes are made.', 'thinkrank' )
1197 : __( 'Read and improve your SEO across posts and site settings metadata, schema, sitemaps, robots, social, indexing, and SEO scores.', 'thinkrank' );
1198 $client = '' !== $req['client_name'] ? $req['client_name'] : __( 'An AI assistant', 'thinkrank' );
1199 $action_url = Mcp_OAuth::authorize_url();
1200 $nonce = wp_create_nonce( 'thinkrank_oauth_consent' );
1201 $user = wp_get_current_user();
1202
1203 // Preserve every OAuth param so the POST re-validates identically.
1204 $hidden = '';
1205 foreach ( [ 'client_id', 'redirect_uri', 'code_challenge', 'scope', 'state' ] as $k ) {
1206 $val = 'scope' === $k ? $req['scope'] : ( $req[ $k ] ?? '' );
1207 $hidden .= sprintf( '<input type="hidden" name="%s" value="%s" />', esc_attr( $k ), esc_attr( (string) $val ) );
1208 }
1209 // code_challenge_method + response_type are re-asserted for validation.
1210 $hidden .= '<input type="hidden" name="code_challenge_method" value="S256" />';
1211 $hidden .= '<input type="hidden" name="response_type" value="code" />';
1212
1213 status_header( 200 );
1214 header( 'Content-Type: text/html; charset=utf-8' );
1215 header( 'Cache-Control: no-store' );
1216
1217 $host = (string) wp_parse_url( home_url(), PHP_URL_HOST );
1218 $logo = '<svg width="36" height="36" viewBox="0 0 29 29" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">'
1219 . '<g clip-path="url(#tr_clip0)">'
1220 . '<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)"/>'
1221 . '<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)"/>'
1222 . '<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)"/>'
1223 . '<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"/>'
1224 . '<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)"/>'
1225 . '<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)"/>'
1226 . '</g>'
1227 . '<rect x="0.353516" y="0.353516" width="28" height="28" rx="7.76216" stroke="#B6CBFF" stroke-width="0.707321"/>'
1228 . '<defs>'
1229 . '<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>'
1230 . '<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>'
1231 . '<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>'
1232 . '<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>'
1233 . '<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>'
1234 . '<clipPath id="tr_clip0"><rect x="0.353516" y="0.353516" width="28" height="28" rx="7.76216" fill="white"/></clipPath>'
1235 . '</defs></svg>';
1236 $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>';
1237
1238 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>';
1239 echo '<style>'
1240 . ':root{color-scheme:dark}*{box-sizing:border-box}'
1241 . '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}'
1242 . '.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)}'
1243 . '.brand{display:flex;align-items:center;gap:10px;margin-bottom:22px}'
1244 . '.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)}'
1245 . '.brand b{font-size:14px;font-weight:700;letter-spacing:.02em}'
1246 . 'h1{font-size:20px;font-weight:700;margin:0 0 6px}'
1247 . '.sub{color:#9aa6be;font-size:13.5px;margin:0 0 22px}.sub strong{color:#e7ecf3;font-weight:600}'
1248 . '.rows{border:1px solid #263149;border-radius:14px;overflow:hidden;margin-bottom:16px}'
1249 . '.row{display:flex;justify-content:space-between;align-items:center;gap:16px;padding:13px 16px;font-size:13.5px}'
1250 . '.row+.row,.access{border-top:1px solid #263149}'
1251 . '.row .k{color:#9aa6be}.row .v{font-weight:600;text-align:right;word-break:break-word}'
1252 . '.access{padding:14px 16px;background:rgba(99,102,241,.07)}'
1253 . '.access .k{color:#9aa6be;font-size:13px;margin-bottom:8px}'
1254 . '.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)}'
1255 . '.badge.ro{background:rgba(245,158,11,.15);color:#fcd9a1;border-color:rgba(245,158,11,.4)}'
1256 . '.access .d{color:#c3ccdd;font-size:13px;margin-top:8px}'
1257 . '.note{display:flex;align-items:center;gap:7px;color:#7f8aa3;font-size:12px;margin:0 0 20px}'
1258 . '.actions{display:flex;gap:12px}'
1259 . '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)}'
1260 . '.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)}'
1261 . '.deny{background:transparent;color:#aeb8cc;border:1px solid #33405c}.deny:hover{background:rgba(255,255,255,.04)}'
1262 . '</style></head><body><div class="card">';
1263
1264 echo '<div class="brand"><span class="logo">' . $logo . '</span><b>ThinkRank</b></div>'; // phpcs:ignore WordPress.Security.EscapeOutput -- static markup.
1265 echo '<h1>' . esc_html__( 'Connect to ThinkRank', 'thinkrank' ) . '</h1>';
1266 $sub = sprintf(
1267 /* translators: %s: AI client name, already escaped and wrapped in <strong>. */
1268 esc_html__( '%s wants to manage SEO on this site.', 'thinkrank' ),
1269 '<strong>' . esc_html( $client ) . '</strong>'
1270 );
1271 echo '<p class="sub">' . $sub . '</p>'; // phpcs:ignore WordPress.Security.EscapeOutput -- static translation; client name esc_html'd.
1272
1273 echo '<div class="rows">';
1274 echo '<div class="row"><span class="k">' . esc_html__( 'Site', 'thinkrank' ) . '</span><span class="v">' . esc_html( $host ) . '</span></div>';
1275 echo '<div class="row"><span class="k">' . esc_html__( 'Signed in as', 'thinkrank' ) . '</span><span class="v">' . esc_html( $user->user_login ) . '</span></div>';
1276 echo '<div class="access"><div class="k">' . esc_html__( 'Access', 'thinkrank' ) . '</div>';
1277 echo '<span class="badge ' . ( $read_only ? 'ro' : '' ) . '">' . esc_html( $access_label ) . '</span>';
1278 echo '<div class="d">' . esc_html( $access_desc ) . '</div></div>';
1279 echo '</div>';
1280
1281 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.
1282
1283 echo '<form method="post" action="' . esc_url( $action_url ) . '">';
1284 echo $hidden; // phpcs:ignore WordPress.Security.EscapeOutput -- built from esc_attr() above.
1285 echo '<input type="hidden" name="_thinkrank_oauth_nonce" value="' . esc_attr( $nonce ) . '" />';
1286 echo '<div class="actions">';
1287 echo '<button class="deny" name="deny" value="1">' . esc_html__( 'Deny', 'thinkrank' ) . '</button>';
1288 echo '<button class="approve" name="approve" value="1">' . esc_html__( 'Approve', 'thinkrank' ) . '</button>';
1289 echo '</div></form></div></body></html>';
1290 exit;
1291 }
1292
1293 /**
1294 * Render a standalone OAuth error page (no redirect).
1295 *
1296 * @param string $message Error message.
1297 * @return void
1298 */
1299 private function emit_oauth_error_page( string $message ): void {
1300 status_header( 400 );
1301 header( 'Content-Type: text/html; charset=utf-8' );
1302 header( 'Cache-Control: no-store' );
1303 echo '<!doctype html><html><head><meta charset="utf-8"><title>' . esc_html__( 'Authorization error', 'thinkrank' ) . '</title>';
1304 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}'
1305 . '.card{background:#1e293b;border:1px solid #334155;border-radius:16px;max-width:440px;padding:32px;text-align:center}</style></head><body>';
1306 echo '<div class="card"><h1>' . esc_html__( 'Could not authorize', 'thinkrank' ) . '</h1><p>' . esc_html( $message ) . '</p></div></body></html>';
1307 exit;
1308 }
1309
1310 // -- Helpers --
1311
1312 /**
1313 * Read one /authorize parameter verbatim.
1314 *
1315 * Deliberately NOT sanitize_text_field(). That function exists to make
1316 * untrusted text safe to store and display, and part of what it does is
1317 * strip %XX sequences as an anti-obfuscation measure. Applied to an OAuth
1318 * protocol value it quietly changes the value's meaning.
1319 *
1320 * The concrete failure: registration stores redirect_uris raw from a JSON
1321 * body, but at /authorize the same URI arrives as a query parameter, so
1322 * PHP has already URL-decoded it — and sanitising then removed the percent
1323 * sequences. A client registered with `.../cb?next=%2Fdashboard` was
1324 * compared as `.../cb?next=dashboard`, failed the strict match, and was
1325 * told `invalid_redirect_uri` for sending exactly what it registered
1326 * (#487). `state` has the same problem: it is opaque to us and must
1327 * round-trip byte for byte, or the client aborts its own callback.
1328 *
1329 * Protocol identifiers want validation and rejection, not cleaning. Every
1330 * value read here is constrained somewhere better suited to it:
1331 * - redirect_uri strict in_array() against the client's registered set
1332 * - client_id must resolve to a registered client
1333 * - response_type must equal 'code'
1334 * - code_challenge_method must equal 'S256'
1335 * - code_challenge validated against the RFC 7636 character set
1336 * - scope intersected with SUPPORTED_SCOPES
1337 * - state opaque; escaped at output (esc_attr / rawurlencode)
1338 * - approve/deny tested for emptiness only
1339 * - the nonce passed to wp_verify_nonce()
1340 *
1341 * An array value (`?state[]=x`) reads as absent rather than becoming the
1342 * string "Array".
1343 *
1344 * @since 2.1.0
1345 *
1346 * @param array<string,mixed> $source $_GET or $_POST.
1347 * @param string $key Parameter name.
1348 * @return string
1349 */
1350 private static function oauth_param( array $source, string $key ): string {
1351 if ( ! isset( $source[ $key ] ) || ! is_scalar( $source[ $key ] ) ) {
1352 return '';
1353 }
1354
1355 return (string) wp_unslash( $source[ $key ] );
1356 }
1357
1358 /**
1359 * Read an inbound HTTP header from $_SERVER (for the pretty path).
1360 *
1361 * Mirrors WP_REST_Server::get_headers(): on Apache with CGI/FastCGI/suPHP the
1362 * Authorization header never lands in HTTP_AUTHORIZATION. WordPress's own
1363 * .htaccess passthrough re-publishes it as REDIRECT_HTTP_AUTHORIZATION, and a
1364 * few Apache module setups populate neither key but do answer getallheaders().
1365 * The REST route gets this handling from core; the pretty route builds its own
1366 * WP_REST_Request, so it has to do the same here or it 401s on those hosts.
1367 *
1368 * @param string $name Header name.
1369 * @return string|null
1370 */
1371 private static function server_header( string $name ): ?string {
1372 $key = 'HTTP_' . strtoupper( str_replace( '-', '_', $name ) );
1373
1374 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- token compared constant-time downstream; raw header needed verbatim.
1375 if ( isset( $_SERVER[ $key ] ) && '' !== $_SERVER[ $key ] ) {
1376 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- as above.
1377 return wp_unslash( $_SERVER[ $key ] );
1378 }
1379
1380 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- as above.
1381 if ( isset( $_SERVER[ 'REDIRECT_' . $key ] ) && '' !== $_SERVER[ 'REDIRECT_' . $key ] ) {
1382 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- as above.
1383 return wp_unslash( $_SERVER[ 'REDIRECT_' . $key ] );
1384 }
1385
1386 if ( function_exists( 'getallheaders' ) ) {
1387 $headers = getallheaders();
1388 if ( is_array( $headers ) ) {
1389 foreach ( $headers as $header => $value ) {
1390 if ( 0 === strcasecmp( (string) $header, $name ) && '' !== (string) $value ) {
1391 return (string) $value;
1392 }
1393 }
1394 }
1395 }
1396
1397 return null;
1398 }
1399
1400 /**
1401 * Emit a WP_REST_Response as a JSON HTTP response and stop.
1402 *
1403 * @param \WP_REST_Response $response Response to emit.
1404 * @return void
1405 */
1406 private function emit_json( \WP_REST_Response $response ): void {
1407 status_header( $response->get_status() );
1408 // MCP Streamable HTTP: advertise the protocol version we speak so a
1409 // strict client can pin it. We answer JSON (a spec-permitted response
1410 // type); we never open an SSE stream, so no session header is needed.
1411 header( 'MCP-Protocol-Version: ' . Mcp_Server::PROTOCOL_VERSION );
1412 // Never cached. The pretty endpoint can carry the pairing token in its
1413 // path, so a shared cache or proxy holding a response keyed on that URL
1414 // would keep an admin-equivalent credential in its store (#396).
1415 header( 'Cache-Control: no-store, private' );
1416 // Forward any headers the handler set (notably WWW-Authenticate on a
1417 // 401, which drives the OAuth discovery flow).
1418 foreach ( $response->get_headers() as $name => $value ) {
1419 // Re-assert the status on every header: PHP special-cases
1420 // WWW-Authenticate and forces a 401 when no status is given,
1421 // which would silently mask the 429 lockout response.
1422 header( $name . ': ' . $value, true, $response->get_status() );
1423 }
1424 $data = $response->get_data();
1425 if ( null !== $data ) {
1426 header( 'Content-Type: application/json; charset=utf-8' );
1427 echo wp_json_encode( $data );
1428 }
1429 exit;
1430 }
1431 }
1432