| 1 |
<?php |
| 2 |
/** |
| 3 |
* MCP manager — the site side of ThinkRank's MCP integration. |
| 4 |
* |
| 5 |
* The plugin speaks the MCP protocol DIRECTLY at this site's own URL. The |
| 6 |
* user pastes their own site's MCP endpoint + connection token into their AI |
| 7 |
* client — or, for OAuth-capable clients (claude.ai remote connectors), just |
| 8 |
* the URL: |
| 9 |
* |
| 10 |
* https://thissite.com/thinkrank/mcp (pretty, via rewrite) |
| 11 |
* https://thissite.com/wp-json/thinkrank/v1/mcp (always-on fallback) |
| 12 |
* |
| 13 |
* The MCP JSON-RPC handling lives in Mcp_Server; the tool surface is the |
| 14 |
* abilities registry (Mcp_Tools). Auth is the per-site connection token |
| 15 |
* (Mcp_Pairing) or an OAuth 2.1 access token (Mcp_OAuth). |
| 16 |
* |
| 17 |
* Admin-only management routes (manage_options) drive the MCP page: |
| 18 |
* /mcp/connection, /mcp/connect, /mcp/rotate, /mcp/disconnect. |
| 19 |
* |
| 20 |
* The token-only MCP + OAuth routes are exempted from Role_Manager's |
| 21 |
* namespace-wide capability gate (they authenticate inside the handler); the |
| 22 |
* exemption lives in Role_Manager::gate_rest(). |
| 23 |
* |
| 24 |
* A single admin toggle (`enable_mcp`) is the master switch: when off, the |
| 25 |
* MCP endpoint, discovery documents, and OAuth endpoints all refuse to serve. |
| 26 |
* |
| 27 |
* @package ThinkRank\Mcp |
| 28 |
*/ |
| 29 |
|
| 30 |
declare(strict_types=1); |
| 31 |
|
| 32 |
namespace ThinkRank\Mcp; |
| 33 |
|
| 34 |
use ThinkRank\Core\Settings; |
| 35 |
|
| 36 |
if ( ! defined( 'ABSPATH' ) ) { |
| 37 |
exit; // Exit if accessed directly. |
| 38 |
} |
| 39 |
|
| 40 |
/** |
| 41 |
* Registers the MCP endpoint, OAuth discovery/authorize/token surface, and |
| 42 |
* the admin management routes. |
| 43 |
*/ |
| 44 |
final class Mcp_Manager { |
| 45 |
|
| 46 |
/** |
| 47 |
* REST namespace shared with the rest of the plugin. |
| 48 |
*/ |
| 49 |
private const NS = 'thinkrank/v1'; |
| 50 |
|
| 51 |
/** |
| 52 |
* Query var flagging a pretty /thinkrank/mcp request. |
| 53 |
*/ |
| 54 |
private const QUERY_VAR = 'thinkrank_mcp'; |
| 55 |
|
| 56 |
/** |
| 57 |
* Query var carrying the token when embedded in the URL path. |
| 58 |
*/ |
| 59 |
private const TOKEN_QUERY_VAR = 'thinkrank_mcp_token'; |
| 60 |
|
| 61 |
/** |
| 62 |
* Query var flagging a /.well-known/ OAuth discovery request. |
| 63 |
*/ |
| 64 |
private const WELLKNOWN_QUERY_VAR = 'thinkrank_mcp_wellknown'; |
| 65 |
|
| 66 |
/** |
| 67 |
* Query var flagging the browser-facing OAuth authorize page. This is |
| 68 |
* served OUTSIDE the REST API on purpose: a REST route only honors cookie |
| 69 |
* auth when a REST nonce accompanies it, but a browser arriving from |
| 70 |
* wp-login carries the cookie with NO nonce — so is_user_logged_in() |
| 71 |
* would be false there and the consent screen would loop back to login |
| 72 |
* forever. A normal front-end URL (rewrite + parse_request) sees standard |
| 73 |
* cookie auth, so the logged-in admin check works. |
| 74 |
*/ |
| 75 |
private const AUTHORIZE_QUERY_VAR = 'thinkrank_mcp_authorize'; |
| 76 |
|
| 77 |
/** |
| 78 |
* Initialize (called by the plugin's component container). |
| 79 |
* |
| 80 |
* @return void |
| 81 |
*/ |
| 82 |
public function init(): void { |
| 83 |
add_action( 'rest_api_init', [ $this, 'register_rest' ] ); |
| 84 |
|
| 85 |
// Published /.well-known/ files are served ahead of WordPress, so a |
| 86 |
// site URL change leaves them advertising the old domain's issuer with |
| 87 |
// nothing to correct them. Registered unconditionally: a stale |
| 88 |
// document is harmful whether or not MCP is currently enabled (#486). |
| 89 |
Mcp_Static_Discovery::init(); |
| 90 |
|
| 91 |
// Pretty per-site endpoint: /thinkrank/mcp → MCP JSON-RPC handler. |
| 92 |
add_action( 'init', [ $this, 'add_rewrite' ] ); |
| 93 |
add_filter( 'query_vars', [ $this, 'register_query_var' ] ); |
| 94 |
add_action( 'parse_request', [ $this, 'maybe_handle_pretty_endpoint' ] ); |
| 95 |
|
| 96 |
// The one broken state the server can't see from inside a request: |
| 97 |
// MCP enabled but the bundled Abilities runtime absent. Everything |
| 98 |
// else still works — OAuth discovers, tokens mint, clients connect — |
| 99 |
// and tools/list is an empty array served as success. Three layers |
| 100 |
// each "no-op gracefully" (the is_readable() require, the registrar, |
| 101 |
// the tool registry) and composed they manufacture a connector that |
| 102 |
// connects and offers nothing, with no signal anywhere. Say it loudly |
| 103 |
// where an admin will look. |
| 104 |
add_action( 'admin_notices', [ $this, 'warn_when_runtime_missing' ] ); |
| 105 |
} |
| 106 |
|
| 107 |
/** |
| 108 |
* Admin notice when MCP is enabled but the Abilities runtime is missing. |
| 109 |
* |
| 110 |
* That combination almost always means an incomplete package — a source |
| 111 |
* archive or a zip built without `dependencies/vendor/` (the bundled |
| 112 |
* Abilities API + MCP adapter). Shown to admins on every screen: the fix |
| 113 |
* is reinstalling the plugin, and a user mid-support-ticket needs to see |
| 114 |
* it without knowing which screen to visit. |
| 115 |
* |
| 116 |
* @return void |
| 117 |
*/ |
| 118 |
public function warn_when_runtime_missing(): void { |
| 119 |
if ( ! self::is_enabled() || function_exists( 'wp_register_ability' ) ) { |
| 120 |
return; |
| 121 |
} |
| 122 |
if ( ! current_user_can( 'manage_options' ) ) { |
| 123 |
return; |
| 124 |
} |
| 125 |
printf( |
| 126 |
'<div class="notice notice-error"><p><strong>%s</strong> %s</p></div>', |
| 127 |
esc_html__( 'ThinkRank MCP: AI assistants will connect but see no tools.', 'thinkrank' ), |
| 128 |
esc_html__( 'MCP access is enabled, but the bundled Abilities runtime (dependencies/vendor) is missing from this installation — usually a plugin package built without it. Reinstall ThinkRank from wordpress.org or an official build; until then, connected AI clients get an empty tool list.', 'thinkrank' ) |
| 129 |
); |
| 130 |
} |
| 131 |
|
| 132 |
/** |
| 133 |
* Whether the MCP integration is enabled via the admin setting. |
| 134 |
* |
| 135 |
* @return bool |
| 136 |
*/ |
| 137 |
public static function is_enabled(): bool { |
| 138 |
return (bool) Settings::instance()->get( 'enable_mcp', false ); |
| 139 |
} |
| 140 |
|
| 141 |
// -- Pretty endpoint: /thinkrank/mcp -- |
| 142 |
|
| 143 |
/** |
| 144 |
* Register rewrite rules for the MCP endpoint, OAuth discovery documents, |
| 145 |
* and the browser-facing authorize page. |
| 146 |
* |
| 147 |
* @return void |
| 148 |
*/ |
| 149 |
public function add_rewrite(): void { |
| 150 |
// Token-in-URL form: /thinkrank/mcp/<token> — a single string the user |
| 151 |
// pastes into their AI client (no separate token field). The bare |
| 152 |
// /thinkrank/mcp still works with a Bearer token. |
| 153 |
add_rewrite_rule( |
| 154 |
'^thinkrank/mcp/([a-f0-9]{64})/?$', |
| 155 |
'index.php?' . self::QUERY_VAR . '=1&' . self::TOKEN_QUERY_VAR . '=$matches[1]', |
| 156 |
'top' |
| 157 |
); |
| 158 |
add_rewrite_rule( '^thinkrank/mcp/?$', 'index.php?' . self::QUERY_VAR . '=1', 'top' ); |
| 159 |
|
| 160 |
// OAuth discovery documents. RFC 9728 §3.1 / RFC 8414 §3.1 place the |
| 161 |
// `.well-known` segment BEFORE the resource path, so our resource at |
| 162 |
// /thinkrank/mcp is discovered at the path-suffixed form: |
| 163 |
// /.well-known/oauth-protected-resource/thinkrank/mcp |
| 164 |
// /.well-known/oauth-authorization-server/thinkrank/mcp |
| 165 |
// The OAuth issuer is the path-based identifier home_url('/thinkrank/mcp') |
| 166 |
// (see Mcp_OAuth::issuer), so spec-compliant clients derive exactly |
| 167 |
// these URLs — and the rule stays specific to OUR path. That matters |
| 168 |
// for coexistence: another plugin serving its own MCP OAuth surface |
| 169 |
// (e.g. xSpeed) claims the generic `(?:/.*)?` root rule, and rewrite |
| 170 |
// rules are keyed by regex, so a shared broad rule would be silently |
| 171 |
// overwritten by whichever plugin registers last. |
| 172 |
add_rewrite_rule( |
| 173 |
'^\.well-known/oauth-(protected-resource|authorization-server)/thinkrank/mcp/?$', |
| 174 |
'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]', |
| 175 |
'top' |
| 176 |
); |
| 177 |
// Root-form fallback for clients that only try the bare well-known |
| 178 |
// URL. Harmless when another plugin also registers this exact regex — |
| 179 |
// last registrant wins, and our clients use the path-suffixed form. |
| 180 |
add_rewrite_rule( |
| 181 |
'^\.well-known/oauth-(protected-resource|authorization-server)(?:/.*)?/?$', |
| 182 |
'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]', |
| 183 |
'top' |
| 184 |
); |
| 185 |
// Suffix form: <issuer>/.well-known/... . RFC 8414 specifies the |
| 186 |
// path-INSERT form above, but the older OpenID Connect Discovery |
| 187 |
// convention appends instead, and clients built on an OIDC library |
| 188 |
// try that shape first (sometimes only that shape). Serving both |
| 189 |
// costs two rules and removes a whole class of "server does not |
| 190 |
// implement OAuth" failures from clients that never fall back. |
| 191 |
add_rewrite_rule( |
| 192 |
'^thinkrank/mcp/\.well-known/oauth-(protected-resource|authorization-server)/?$', |
| 193 |
'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]', |
| 194 |
'top' |
| 195 |
); |
| 196 |
add_rewrite_rule( |
| 197 |
'^thinkrank/mcp/\.well-known/openid-configuration/?$', |
| 198 |
'index.php?' . self::WELLKNOWN_QUERY_VAR . '=authorization-server', |
| 199 |
'top' |
| 200 |
); |
| 201 |
|
| 202 |
// Browser-facing OAuth consent page — served OUTSIDE REST so cookie |
| 203 |
// auth (is_user_logged_in) works after the wp-login round-trip. |
| 204 |
add_rewrite_rule( '^thinkrank/authorize/?$', 'index.php?' . self::AUTHORIZE_QUERY_VAR . '=1', 'top' ); |
| 205 |
|
| 206 |
// Self-heal: flush once if ANY of our rules is missing from the stored |
| 207 |
// rewrite table, so the endpoints work without a manual permalink |
| 208 |
// re-save (and newly added rules trigger a re-flush on upgrade). |
| 209 |
$expected = [ |
| 210 |
'^thinkrank/mcp/([a-f0-9]{64})/?$', |
| 211 |
'^thinkrank/mcp/?$', |
| 212 |
'^\.well-known/oauth-(protected-resource|authorization-server)/thinkrank/mcp/?$', |
| 213 |
'^thinkrank/mcp/\.well-known/oauth-(protected-resource|authorization-server)/?$', |
| 214 |
'^thinkrank/mcp/\.well-known/openid-configuration/?$', |
| 215 |
'^thinkrank/authorize/?$', |
| 216 |
]; |
| 217 |
$rules = get_option( 'rewrite_rules' ); |
| 218 |
if ( is_array( $rules ) ) { |
| 219 |
foreach ( $expected as $rule ) { |
| 220 |
if ( ! isset( $rules[ $rule ] ) ) { |
| 221 |
flush_rewrite_rules( false ); |
| 222 |
break; |
| 223 |
} |
| 224 |
} |
| 225 |
} |
| 226 |
} |
| 227 |
|
| 228 |
/** |
| 229 |
* Register our query vars. |
| 230 |
* |
| 231 |
* @param string[] $vars Registered query vars. |
| 232 |
* @return string[] |
| 233 |
*/ |
| 234 |
public function register_query_var( array $vars ): array { |
| 235 |
$vars[] = self::QUERY_VAR; |
| 236 |
$vars[] = self::TOKEN_QUERY_VAR; |
| 237 |
$vars[] = self::WELLKNOWN_QUERY_VAR; |
| 238 |
$vars[] = self::AUTHORIZE_QUERY_VAR; |
| 239 |
return $vars; |
| 240 |
} |
| 241 |
|
| 242 |
/** |
| 243 |
* Serve the MCP endpoint on the pretty path. Runs on parse_request so it |
| 244 |
* fires before the main query, and short-circuits WP entirely. |
| 245 |
* |
| 246 |
* @param \WP $wp The WP request object. |
| 247 |
* @return void |
| 248 |
*/ |
| 249 |
public function maybe_handle_pretty_endpoint( $wp ): void { |
| 250 |
// OAuth discovery documents (served at the site root). |
| 251 |
if ( ! empty( $wp->query_vars[ self::WELLKNOWN_QUERY_VAR ] ) ) { |
| 252 |
if ( ! self::is_enabled() ) { |
| 253 |
status_header( 404 ); |
| 254 |
exit; |
| 255 |
} |
| 256 |
$doc = (string) $wp->query_vars[ self::WELLKNOWN_QUERY_VAR ]; |
| 257 |
$data = 'authorization-server' === $doc |
| 258 |
? Mcp_OAuth::authorization_server_metadata() |
| 259 |
: Mcp_OAuth::protected_resource_metadata(); |
| 260 |
status_header( 200 ); |
| 261 |
header( 'Content-Type: application/json; charset=utf-8' ); |
| 262 |
// Discovery metadata is public + cacheable. |
| 263 |
header( 'Cache-Control: public, max-age=3600' ); |
| 264 |
echo wp_json_encode( $data ); |
| 265 |
exit; |
| 266 |
} |
| 267 |
|
| 268 |
// Browser-facing OAuth consent page (cookie auth applies here). |
| 269 |
if ( ! empty( $wp->query_vars[ self::AUTHORIZE_QUERY_VAR ] ) ) { |
| 270 |
if ( ! self::is_enabled() ) { |
| 271 |
status_header( 404 ); |
| 272 |
exit; |
| 273 |
} |
| 274 |
$this->handle_authorize_page(); |
| 275 |
return; |
| 276 |
} |
| 277 |
|
| 278 |
if ( empty( $wp->query_vars[ self::QUERY_VAR ] ) ) { |
| 279 |
return; |
| 280 |
} |
| 281 |
|
| 282 |
$request = new \WP_REST_Request( 'POST', '/' . self::NS . '/mcp' ); |
| 283 |
$request->set_header( 'content-type', 'application/json' ); |
| 284 |
// Carry the auth header + raw body from the live PHP request. |
| 285 |
$auth = self::server_header( 'authorization' ); |
| 286 |
if ( null !== $auth ) { |
| 287 |
$request->set_header( 'authorization', $auth ); |
| 288 |
} |
| 289 |
// Token embedded in the URL path (/thinkrank/mcp/<token>) — surface it |
| 290 |
// as a Bearer header so Mcp_Server validates it the same way. A real |
| 291 |
// Authorization header (if also sent) takes precedence. |
| 292 |
$path_token = isset( $wp->query_vars[ self::TOKEN_QUERY_VAR ] ) |
| 293 |
? (string) $wp->query_vars[ self::TOKEN_QUERY_VAR ] |
| 294 |
: ''; |
| 295 |
if ( '' !== $path_token && '' === (string) $request->get_header( 'authorization' ) ) { |
| 296 |
$request->set_header( 'authorization', 'Bearer ' . $path_token ); |
| 297 |
} |
| 298 |
$request->set_body( (string) file_get_contents( 'php://input' ) ); |
| 299 |
|
| 300 |
$response = Mcp_Server::handle( $request ); |
| 301 |
$this->emit_json( $response ); |
| 302 |
} |
| 303 |
|
| 304 |
// -- REST registration -- |
| 305 |
|
| 306 |
/** |
| 307 |
* Register the REST routes: the MCP JSON-RPC fallback, the admin |
| 308 |
* management routes, and the OAuth registration/token endpoints. |
| 309 |
* |
| 310 |
* @return void |
| 311 |
*/ |
| 312 |
public function register_rest(): void { |
| 313 |
// --- MCP JSON-RPC endpoint (fallback path via wp-json) ----------- |
| 314 |
// permission_callback is __return_true because Mcp_Server does its own |
| 315 |
// token auth and must reply with a JSON-RPC 401 + WWW-Authenticate, |
| 316 |
// not a bare WP permission failure. |
| 317 |
register_rest_route( |
| 318 |
self::NS, |
| 319 |
'/mcp', |
| 320 |
[ |
| 321 |
'methods' => 'POST', |
| 322 |
'callback' => [ $this, 'rest_mcp' ], |
| 323 |
'permission_callback' => '__return_true', |
| 324 |
] |
| 325 |
); |
| 326 |
|
| 327 |
// --- Admin-only management routes (the MCP page) ------------------ |
| 328 |
register_rest_route( |
| 329 |
self::NS, |
| 330 |
'/mcp/connection', |
| 331 |
[ |
| 332 |
'methods' => 'GET', |
| 333 |
'callback' => [ $this, 'rest_connection' ], |
| 334 |
'permission_callback' => [ $this, 'admin_permission' ], |
| 335 |
] |
| 336 |
); |
| 337 |
register_rest_route( |
| 338 |
self::NS, |
| 339 |
'/mcp/connect', |
| 340 |
[ |
| 341 |
'methods' => 'POST', |
| 342 |
'callback' => [ $this, 'rest_connect' ], |
| 343 |
'permission_callback' => [ $this, 'admin_permission' ], |
| 344 |
'args' => [ |
| 345 |
'read_only' => [ |
| 346 |
'type' => 'boolean', |
| 347 |
'required' => false, |
| 348 |
'default' => false, |
| 349 |
'description' => 'Grant read-only access (no SEO metadata or settings changes).', |
| 350 |
], |
| 351 |
], |
| 352 |
] |
| 353 |
); |
| 354 |
register_rest_route( |
| 355 |
self::NS, |
| 356 |
'/mcp/rotate', |
| 357 |
[ |
| 358 |
'methods' => 'POST', |
| 359 |
'callback' => [ $this, 'rest_rotate' ], |
| 360 |
'permission_callback' => [ $this, 'admin_permission' ], |
| 361 |
'args' => [ |
| 362 |
'read_only' => [ |
| 363 |
'type' => 'boolean', |
| 364 |
'required' => false, |
| 365 |
'description' => 'Optionally set read-only on the new token; omit to keep current scopes.', |
| 366 |
], |
| 367 |
], |
| 368 |
] |
| 369 |
); |
| 370 |
register_rest_route( |
| 371 |
self::NS, |
| 372 |
'/mcp/disconnect', |
| 373 |
[ |
| 374 |
'methods' => 'POST', |
| 375 |
'callback' => [ $this, 'rest_disconnect' ], |
| 376 |
'permission_callback' => [ $this, 'admin_permission' ], |
| 377 |
] |
| 378 |
); |
| 379 |
|
| 380 |
// Live round-trip diagnostic for the MCP page (see #189). Admin-only; |
| 381 |
// exercises the endpoint the way an external client would. |
| 382 |
register_rest_route( |
| 383 |
self::NS, |
| 384 |
'/mcp/self-test', |
| 385 |
[ |
| 386 |
'methods' => 'POST', |
| 387 |
'callback' => [ $this, 'rest_self_test' ], |
| 388 |
'permission_callback' => [ $this, 'admin_permission' ], |
| 389 |
] |
| 390 |
); |
| 391 |
|
| 392 |
// Connected AI apps (see #244): list the OAuth-connected clients plus a |
| 393 |
// single combined row for the shared static token, and revoke either. |
| 394 |
register_rest_route( |
| 395 |
self::NS, |
| 396 |
'/mcp/apps', |
| 397 |
[ |
| 398 |
'methods' => 'GET', |
| 399 |
'callback' => [ $this, 'rest_apps' ], |
| 400 |
'permission_callback' => [ $this, 'admin_permission' ], |
| 401 |
] |
| 402 |
); |
| 403 |
register_rest_route( |
| 404 |
self::NS, |
| 405 |
'/mcp/apps/revoke', |
| 406 |
[ |
| 407 |
'methods' => 'POST', |
| 408 |
'callback' => [ $this, 'rest_revoke_app' ], |
| 409 |
'permission_callback' => [ $this, 'admin_permission' ], |
| 410 |
'args' => [ |
| 411 |
'client_id' => [ |
| 412 |
'type' => 'string', |
| 413 |
'required' => true, |
| 414 |
'description' => 'The OAuth client_id to revoke.', |
| 415 |
], |
| 416 |
], |
| 417 |
] |
| 418 |
); |
| 419 |
|
| 420 |
// --- OAuth 2.1 authorization server (the "paste a URL only" path) - |
| 421 |
// Discovery, dynamic client registration, and the token endpoint are |
| 422 |
// all public (permission enforced inside): a client must reach them |
| 423 |
// BEFORE it holds any credential. |
| 424 |
// |
| 425 |
// The discovery documents are ALSO served here, not only at the |
| 426 |
// /.well-known/ rewrites: hosts that resolve root /.well-known/ at |
| 427 |
// their proxy edge (SiteGround) never let those requests reach |
| 428 |
// WordPress, while /wp-json/ always arrives. The 401 challenge |
| 429 |
// advertises this route (Mcp_OAuth::resource_metadata_url), so the |
| 430 |
// flow survives on such hosts. |
| 431 |
register_rest_route( |
| 432 |
self::NS, |
| 433 |
'/mcp/oauth/protected-resource', |
| 434 |
[ |
| 435 |
'methods' => 'GET', |
| 436 |
'callback' => [ $this, 'rest_oauth_discovery_resource' ], |
| 437 |
'permission_callback' => '__return_true', |
| 438 |
] |
| 439 |
); |
| 440 |
register_rest_route( |
| 441 |
self::NS, |
| 442 |
'/mcp/oauth/authorization-server', |
| 443 |
[ |
| 444 |
'methods' => 'GET', |
| 445 |
'callback' => [ $this, 'rest_oauth_discovery_server' ], |
| 446 |
'permission_callback' => '__return_true', |
| 447 |
] |
| 448 |
); |
| 449 |
register_rest_route( |
| 450 |
self::NS, |
| 451 |
'/mcp/oauth/register', |
| 452 |
[ |
| 453 |
'methods' => 'POST', |
| 454 |
'callback' => [ $this, 'rest_oauth_register' ], |
| 455 |
'permission_callback' => '__return_true', |
| 456 |
] |
| 457 |
); |
| 458 |
// NOTE: /authorize is deliberately NOT a REST route — it is served as |
| 459 |
// a normal front-end page at /thinkrank/authorize (see |
| 460 |
// handle_authorize_page) so cookie auth works after wp-login. |
| 461 |
register_rest_route( |
| 462 |
self::NS, |
| 463 |
'/mcp/oauth/token', |
| 464 |
[ |
| 465 |
'methods' => 'POST', |
| 466 |
'callback' => [ $this, 'rest_oauth_token' ], |
| 467 |
'permission_callback' => '__return_true', |
| 468 |
] |
| 469 |
); |
| 470 |
} |
| 471 |
|
| 472 |
/** |
| 473 |
* Capability gate for the admin-only management routes. |
| 474 |
* |
| 475 |
* @return bool |
| 476 |
*/ |
| 477 |
public function admin_permission(): bool { |
| 478 |
return current_user_can( 'manage_options' ); |
| 479 |
} |
| 480 |
|
| 481 |
// -- Handlers ---------------------------------------------------------- |
| 482 |
|
| 483 |
/** |
| 484 |
* MCP JSON-RPC over the wp-json fallback path. |
| 485 |
* |
| 486 |
* @param \WP_REST_Request $request Incoming request. |
| 487 |
* @return \WP_REST_Response |
| 488 |
*/ |
| 489 |
public function rest_mcp( \WP_REST_Request $request ): \WP_REST_Response { |
| 490 |
$response = Mcp_Server::handle( $request ); |
| 491 |
// Advertise the MCP protocol version on the wp-json transport too, so |
| 492 |
// both endpoints behave identically to a strict Streamable-HTTP client. |
| 493 |
$response->header( 'MCP-Protocol-Version', Mcp_Server::PROTOCOL_VERSION ); |
| 494 |
return $response; |
| 495 |
} |
| 496 |
|
| 497 |
/** |
| 498 |
* GET /mcp/connection — pairing status for the MCP page. |
| 499 |
* |
| 500 |
* @return \WP_REST_Response |
| 501 |
*/ |
| 502 |
public function rest_connection(): \WP_REST_Response { |
| 503 |
$this->ensure_connected(); |
| 504 |
return rest_ensure_response( Mcp_Pairing::public_status() ); |
| 505 |
} |
| 506 |
|
| 507 |
/** |
| 508 |
* Self-heal: whenever the admin views the MCP page with MCP enabled, make |
| 509 |
* sure a connection token exists. New sites mint on the enable toggle (see |
| 510 |
* #244), but a site that had MCP on before that behavior shipped would have |
| 511 |
* no token; minting here — idempotent, admin-gated — keeps the connect |
| 512 |
* recipes populated without a separate "Generate token" click. |
| 513 |
* |
| 514 |
* @return void |
| 515 |
*/ |
| 516 |
private function ensure_connected(): void { |
| 517 |
if ( self::is_enabled() && ! Mcp_Pairing::is_connected() ) { |
| 518 |
Mcp_Pairing::connect(); |
| 519 |
} |
| 520 |
} |
| 521 |
|
| 522 |
/** |
| 523 |
* POST /mcp/connect — mint a connection token. |
| 524 |
* |
| 525 |
* @param \WP_REST_Request $request Carries optional read_only. |
| 526 |
* @return \WP_REST_Response |
| 527 |
*/ |
| 528 |
public function rest_connect( \WP_REST_Request $request ): \WP_REST_Response { |
| 529 |
$read_only = (bool) $request->get_param( 'read_only' ); |
| 530 |
return rest_ensure_response( Mcp_Pairing::connect( $read_only ) ); |
| 531 |
} |
| 532 |
|
| 533 |
/** |
| 534 |
* POST /mcp/rotate — mint a fresh token, invalidating the old one. |
| 535 |
* |
| 536 |
* @param \WP_REST_Request $request Carries optional read_only. |
| 537 |
* @return \WP_REST_Response |
| 538 |
*/ |
| 539 |
public function rest_rotate( \WP_REST_Request $request ): \WP_REST_Response { |
| 540 |
$read_only = null; |
| 541 |
if ( null !== $request->get_param( 'read_only' ) ) { |
| 542 |
$read_only = (bool) $request->get_param( 'read_only' ); |
| 543 |
} |
| 544 |
return rest_ensure_response( Mcp_Pairing::rotate( $read_only ) ); |
| 545 |
} |
| 546 |
|
| 547 |
/** |
| 548 |
* POST /mcp/disconnect — revoke the connection token + all OAuth grants. |
| 549 |
* |
| 550 |
* @return \WP_REST_Response |
| 551 |
*/ |
| 552 |
public function rest_disconnect(): \WP_REST_Response { |
| 553 |
return rest_ensure_response( Mcp_Pairing::disconnect() ); |
| 554 |
} |
| 555 |
|
| 556 |
/** |
| 557 |
* POST /mcp/self-test — run the live round-trip diagnostic (see #189). |
| 558 |
* |
| 559 |
* @return \WP_REST_Response |
| 560 |
*/ |
| 561 |
public function rest_self_test(): \WP_REST_Response { |
| 562 |
return rest_ensure_response( Mcp_Self_Test::run() ); |
| 563 |
} |
| 564 |
|
| 565 |
/** |
| 566 |
* GET /mcp/apps — the "Connected AI apps" list (see #244). |
| 567 |
* |
| 568 |
* @return \WP_REST_Response |
| 569 |
*/ |
| 570 |
public function rest_apps(): \WP_REST_Response { |
| 571 |
$this->ensure_connected(); |
| 572 |
return rest_ensure_response( $this->apps_payload() ); |
| 573 |
} |
| 574 |
|
| 575 |
/** |
| 576 |
* POST /mcp/apps/revoke — cut off a single OAuth-connected app. Returns the |
| 577 |
* refreshed app list so the UI updates in one round trip. (The shared static |
| 578 |
* token has no per-client identity, so it is not listed or revoked here — it |
| 579 |
* is rotated from the connect card via /mcp/rotate.) |
| 580 |
* |
| 581 |
* @param \WP_REST_Request $request Carries target + client_id. |
| 582 |
* @return \WP_REST_Response|\WP_Error |
| 583 |
*/ |
| 584 |
public function rest_revoke_app( \WP_REST_Request $request ) { |
| 585 |
$client_id = (string) $request->get_param( 'client_id' ); |
| 586 |
if ( '' === $client_id ) { |
| 587 |
return new \WP_Error( |
| 588 |
'thinkrank_missing_client_id', |
| 589 |
__( 'A client_id is required to revoke an OAuth app.', 'thinkrank' ), |
| 590 |
[ 'status' => 400 ] |
| 591 |
); |
| 592 |
} |
| 593 |
Mcp_OAuth::revoke_client( $client_id ); |
| 594 |
|
| 595 |
return rest_ensure_response( $this->apps_payload() ); |
| 596 |
} |
| 597 |
|
| 598 |
/** |
| 599 |
* Build the "Connected AI apps" payload: the OAuth-connected clients, with |
| 600 |
* the approving admin's display name resolved. Header-based (static-token) |
| 601 |
* clients share one anonymous secret and so are not represented here. |
| 602 |
* |
| 603 |
* @return array<string,mixed> |
| 604 |
*/ |
| 605 |
private function apps_payload(): array { |
| 606 |
$oauth_apps = []; |
| 607 |
foreach ( Mcp_OAuth::connected_apps() as $app ) { |
| 608 |
$user = $app['user_id'] > 0 ? get_userdata( $app['user_id'] ) : false; |
| 609 |
$oauth_apps[] = [ |
| 610 |
'client_id' => $app['client_id'], |
| 611 |
'name' => $app['name'], |
| 612 |
'read_only' => $app['read_only'], |
| 613 |
'approved_by' => $user ? $user->display_name : __( 'Unknown user', 'thinkrank' ), |
| 614 |
'connected_at' => $app['connected_at'], |
| 615 |
'last_used' => $app['last_used'], |
| 616 |
]; |
| 617 |
} |
| 618 |
|
| 619 |
return [ |
| 620 |
'oauth_apps' => $oauth_apps, |
| 621 |
]; |
| 622 |
} |
| 623 |
|
| 624 |
// -- OAuth 2.1 handlers ------------------------------------------------ |
| 625 |
|
| 626 |
/** |
| 627 |
* GET /mcp/oauth/protected-resource — RFC 9728 metadata via REST. |
| 628 |
* |
| 629 |
* @return \WP_REST_Response|\WP_Error |
| 630 |
*/ |
| 631 |
public function rest_oauth_discovery_resource() { |
| 632 |
return $this->oauth_discovery_response( Mcp_OAuth::protected_resource_metadata() ); |
| 633 |
} |
| 634 |
|
| 635 |
/** |
| 636 |
* GET /mcp/oauth/authorization-server — RFC 8414 metadata via REST. |
| 637 |
* |
| 638 |
* @return \WP_REST_Response|\WP_Error |
| 639 |
*/ |
| 640 |
public function rest_oauth_discovery_server() { |
| 641 |
return $this->oauth_discovery_response( Mcp_OAuth::authorization_server_metadata() ); |
| 642 |
} |
| 643 |
|
| 644 |
/** |
| 645 |
* Shape one discovery document response: public, cacheable, and 404 when |
| 646 |
* MCP is off — matching the /.well-known/ rewrites exactly, so a client |
| 647 |
* sees the same truth regardless of which serving path reached it. |
| 648 |
* |
| 649 |
* @param array<string,mixed> $document Discovery metadata. |
| 650 |
* @return \WP_REST_Response|\WP_Error |
| 651 |
*/ |
| 652 |
private function oauth_discovery_response( array $document ) { |
| 653 |
if ( ! self::is_enabled() ) { |
| 654 |
return new \WP_Error( 'thinkrank_mcp_disabled', __( 'MCP is disabled on this site.', 'thinkrank' ), [ 'status' => 404 ] ); |
| 655 |
} |
| 656 |
$response = new \WP_REST_Response( $document, 200 ); |
| 657 |
$response->header( 'Cache-Control', 'public, max-age=3600' ); |
| 658 |
return $response; |
| 659 |
} |
| 660 |
|
| 661 |
/** |
| 662 |
* POST /mcp/oauth/register — RFC 7591 dynamic client registration. |
| 663 |
* |
| 664 |
* @param \WP_REST_Request $request JSON body with redirect_uris. |
| 665 |
* @return \WP_REST_Response|\WP_Error |
| 666 |
*/ |
| 667 |
public function rest_oauth_register( \WP_REST_Request $request ) { |
| 668 |
if ( ! self::is_enabled() ) { |
| 669 |
return new \WP_Error( 'thinkrank_mcp_disabled', __( 'MCP is disabled on this site.', 'thinkrank' ), [ 'status' => 403 ] ); |
| 670 |
} |
| 671 |
$body = $request->get_json_params(); |
| 672 |
if ( ! is_array( $body ) ) { |
| 673 |
$body = []; |
| 674 |
} |
| 675 |
$result = Mcp_OAuth::register_client( $body ); |
| 676 |
if ( is_wp_error( $result ) ) { |
| 677 |
return $result; |
| 678 |
} |
| 679 |
return new \WP_REST_Response( $result, 201 ); |
| 680 |
} |
| 681 |
|
| 682 |
/** |
| 683 |
* POST /mcp/oauth/token — exchange a code (or refresh token) for tokens. |
| 684 |
* |
| 685 |
* @param \WP_REST_Request $request Form-encoded or JSON token request. |
| 686 |
* @return \WP_REST_Response |
| 687 |
*/ |
| 688 |
public function rest_oauth_token( \WP_REST_Request $request ): \WP_REST_Response { |
| 689 |
if ( ! self::is_enabled() ) { |
| 690 |
$response = new \WP_REST_Response( |
| 691 |
[ |
| 692 |
'error' => 'invalid_request', |
| 693 |
'error_description' => 'MCP is disabled on this site.', |
| 694 |
], |
| 695 |
403 |
| 696 |
); |
| 697 |
$response->header( 'Cache-Control', 'no-store' ); |
| 698 |
return $response; |
| 699 |
} |
| 700 |
|
| 701 |
// Token requests are application/x-www-form-urlencoded per OAuth, but |
| 702 |
// accept JSON too. get_body_params() covers the form case. |
| 703 |
$body = $request->get_body_params(); |
| 704 |
if ( empty( $body ) ) { |
| 705 |
$json = $request->get_json_params(); |
| 706 |
$body = is_array( $json ) ? $json : []; |
| 707 |
} |
| 708 |
$body = array_map( 'strval', $body ); |
| 709 |
|
| 710 |
$result = Mcp_OAuth::exchange_token( $body ); |
| 711 |
if ( is_wp_error( $result ) ) { |
| 712 |
$data = $result->get_error_data(); |
| 713 |
$response = new \WP_REST_Response( |
| 714 |
[ |
| 715 |
'error' => isset( $data['error'] ) ? $data['error'] : 'invalid_request', |
| 716 |
'error_description' => isset( $data['error_description'] ) ? $data['error_description'] : $result->get_error_message(), |
| 717 |
], |
| 718 |
isset( $data['status'] ) ? (int) $data['status'] : 400 |
| 719 |
); |
| 720 |
$response->header( 'Cache-Control', 'no-store' ); |
| 721 |
return $response; |
| 722 |
} |
| 723 |
$response = new \WP_REST_Response( $result, 200 ); |
| 724 |
$response->header( 'Cache-Control', 'no-store' ); |
| 725 |
$response->header( 'Pragma', 'no-cache' ); |
| 726 |
return $response; |
| 727 |
} |
| 728 |
|
| 729 |
// -- OAuth authorize page ------------------------------------------------ |
| 730 |
|
| 731 |
/** |
| 732 |
* The browser-facing OAuth authorize page (served at /thinkrank/authorize |
| 733 |
* via a rewrite, NOT the REST API). Reads request params from the |
| 734 |
* superglobals because this is a normal front-end request where cookie |
| 735 |
* auth populates is_user_logged_in(). |
| 736 |
* |
| 737 |
* GET renders the consent screen (requires a logged-in admin; anonymous |
| 738 |
* users go to wp-login and return here). POST is the nonce-checked consent |
| 739 |
* submission: Approve issues a code and 302s to the client's redirect_uri; |
| 740 |
* Deny 302s back with error=access_denied. Always emits its own response |
| 741 |
* (HTML page or redirect) and exits. |
| 742 |
* |
| 743 |
* @return void |
| 744 |
*/ |
| 745 |
public function handle_authorize_page(): void { |
| 746 |
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- compared against a literal after strtoupper(); nothing is stored or echoed. |
| 747 |
$is_post = isset( $_SERVER['REQUEST_METHOD'] ) && 'POST' === strtoupper( (string) wp_unslash( $_SERVER['REQUEST_METHOD'] ) ); |
| 748 |
// Params come from GET on the consent link and POST on the form submit. |
| 749 |
// Nonce is verified below before any POST value is acted on. |
| 750 |
// phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- read verbatim by oauth_param(); see its docblock for why, and where each value is validated or escaped instead. |
| 751 |
$source = $is_post ? $_POST : $_GET; |
| 752 |
// phpcs:enable WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized |
| 753 |
$params = []; |
| 754 |
foreach ( [ 'client_id', 'redirect_uri', 'response_type', 'code_challenge', 'code_challenge_method', 'scope', 'state', 'approve', 'deny', '_thinkrank_oauth_nonce' ] as $k ) { |
| 755 |
$params[ $k ] = self::oauth_param( $source, $k ); |
| 756 |
} |
| 757 |
|
| 758 |
// Validate the OAuth params before touching the session. |
| 759 |
$req = Mcp_OAuth::validate_authorize_request( $params ); |
| 760 |
if ( is_wp_error( $req ) ) { |
| 761 |
$data = $req->get_error_data(); |
| 762 |
$redirectable = is_array( $data ) && ! empty( $data['redirectable'] ); |
| 763 |
// Only redirect the error back when redirect_uri is verified valid; |
| 764 |
// otherwise show a page (never bounce to an unverified URL). |
| 765 |
if ( $redirectable && '' !== $params['redirect_uri'] ) { |
| 766 |
$this->redirect_error( $params['redirect_uri'], $req->get_error_code(), $req->get_error_message(), $params['state'] ); |
| 767 |
} |
| 768 |
$this->emit_oauth_error_page( $req->get_error_message() ); |
| 769 |
} |
| 770 |
|
| 771 |
// Require a logged-in admin. Anonymous → wp-login, back to this URL. |
| 772 |
if ( ! is_user_logged_in() ) { |
| 773 |
$this->redirect_to_login(); |
| 774 |
} |
| 775 |
if ( ! current_user_can( 'manage_options' ) ) { |
| 776 |
$this->emit_oauth_error_page( |
| 777 |
__( 'You must be an administrator to authorize an AI assistant to manage SEO on this site.', 'thinkrank' ) |
| 778 |
); |
| 779 |
} |
| 780 |
|
| 781 |
// POST = consent form submitted. |
| 782 |
if ( $is_post ) { |
| 783 |
if ( ! wp_verify_nonce( $params['_thinkrank_oauth_nonce'], 'thinkrank_oauth_consent' ) ) { |
| 784 |
$this->emit_oauth_error_page( __( 'Security check failed. Please try connecting again.', 'thinkrank' ) ); |
| 785 |
} |
| 786 |
if ( '' === $params['approve'] ) { |
| 787 |
$this->redirect_error( $req['redirect_uri'], 'access_denied', 'The user denied the request.', $req['state'] ); |
| 788 |
} |
| 789 |
$code = Mcp_OAuth::issue_code( $req, get_current_user_id() ); |
| 790 |
$this->redirect_success( $req['redirect_uri'], $code, $req['state'] ); |
| 791 |
} |
| 792 |
|
| 793 |
// GET = render the consent screen. |
| 794 |
$this->emit_consent_screen( $req ); |
| 795 |
} |
| 796 |
|
| 797 |
// -- OAuth browser-response helpers ------------------------------------ |
| 798 |
|
| 799 |
/** |
| 800 |
* The absolute URL of the current authorize request (for login return). |
| 801 |
* |
| 802 |
* @return string |
| 803 |
*/ |
| 804 |
private function current_authorize_url(): string { |
| 805 |
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- reconstructing the current URL for a login round-trip; escaped at use. |
| 806 |
$uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : ''; |
| 807 |
return home_url( $uri ); |
| 808 |
} |
| 809 |
|
| 810 |
/** |
| 811 |
* Send an anonymous visitor to wp-login, returning to this authorize URL. |
| 812 |
* |
| 813 |
* @return void |
| 814 |
*/ |
| 815 |
private function redirect_to_login(): void { |
| 816 |
wp_safe_redirect( wp_login_url( $this->current_authorize_url() ) ); |
| 817 |
exit; |
| 818 |
} |
| 819 |
|
| 820 |
/** |
| 821 |
* 302 back to the client with the authorization code (+ state). |
| 822 |
* |
| 823 |
* @param string $redirect_uri Validated client redirect URI. |
| 824 |
* @param string $code Authorization code. |
| 825 |
* @param string $state Client state. |
| 826 |
* @return void |
| 827 |
*/ |
| 828 |
private function redirect_success( string $redirect_uri, string $code, string $state ): void { |
| 829 |
$args = [ 'code' => $code ]; |
| 830 |
if ( '' !== $state ) { |
| 831 |
$args['state'] = $state; |
| 832 |
} |
| 833 |
// Not wp_safe_redirect: redirect_uri is a client-registered off-site |
| 834 |
// callback, already validated against the client's registered set. |
| 835 |
wp_redirect( add_query_arg( $args, $redirect_uri ) ); // phpcs:ignore WordPress.Security.SafeRedirect -- validated OAuth redirect_uri. |
| 836 |
exit; |
| 837 |
} |
| 838 |
|
| 839 |
/** |
| 840 |
* 302 back to the client with an OAuth error (+ state). |
| 841 |
* |
| 842 |
* @param string $redirect_uri Validated client redirect URI. |
| 843 |
* @param string $error OAuth error code. |
| 844 |
* @param string $description Human-readable description. |
| 845 |
* @param string $state Client state. |
| 846 |
* @return void |
| 847 |
*/ |
| 848 |
private function redirect_error( string $redirect_uri, string $error, string $description, string $state ): void { |
| 849 |
$args = [ |
| 850 |
'error' => $error, |
| 851 |
'error_description' => $description, |
| 852 |
]; |
| 853 |
if ( '' !== $state ) { |
| 854 |
$args['state'] = $state; |
| 855 |
} |
| 856 |
wp_redirect( add_query_arg( array_map( 'rawurlencode', $args ), $redirect_uri ) ); // phpcs:ignore WordPress.Security.SafeRedirect -- validated OAuth redirect_uri. |
| 857 |
exit; |
| 858 |
} |
| 859 |
|
| 860 |
/** |
| 861 |
* Render the consent screen. Minimal self-contained HTML (no admin |
| 862 |
* chrome — this is a client-facing OAuth page). Approve/Deny post back |
| 863 |
* to the same authorize URL with a nonce. |
| 864 |
* |
| 865 |
* @param array<string,string> $req Validated authorize params. |
| 866 |
* @return void |
| 867 |
*/ |
| 868 |
private function emit_consent_screen( array $req ): void { |
| 869 |
$read_only = Mcp_OAuth::scope_is_read_only( $req['scope'] ); |
| 870 |
$access_label = $read_only ? __( 'Read-only', 'thinkrank' ) : __( 'Read & write', 'thinkrank' ); |
| 871 |
$access_desc = $read_only |
| 872 |
? __( 'Review your SEO across posts and site settings metadata, schema, sitemaps, robots, social, and SEO scores. No changes are made.', 'thinkrank' ) |
| 873 |
: __( 'Read and improve your SEO across posts and site settings metadata, schema, sitemaps, robots, social, indexing, and SEO scores.', 'thinkrank' ); |
| 874 |
$client = '' !== $req['client_name'] ? $req['client_name'] : __( 'An AI assistant', 'thinkrank' ); |
| 875 |
$action_url = Mcp_OAuth::authorize_url(); |
| 876 |
$nonce = wp_create_nonce( 'thinkrank_oauth_consent' ); |
| 877 |
$user = wp_get_current_user(); |
| 878 |
|
| 879 |
// Preserve every OAuth param so the POST re-validates identically. |
| 880 |
$hidden = ''; |
| 881 |
foreach ( [ 'client_id', 'redirect_uri', 'code_challenge', 'scope', 'state' ] as $k ) { |
| 882 |
$val = 'scope' === $k ? $req['scope'] : ( $req[ $k ] ?? '' ); |
| 883 |
$hidden .= sprintf( '<input type="hidden" name="%s" value="%s" />', esc_attr( $k ), esc_attr( (string) $val ) ); |
| 884 |
} |
| 885 |
// code_challenge_method + response_type are re-asserted for validation. |
| 886 |
$hidden .= '<input type="hidden" name="code_challenge_method" value="S256" />'; |
| 887 |
$hidden .= '<input type="hidden" name="response_type" value="code" />'; |
| 888 |
|
| 889 |
status_header( 200 ); |
| 890 |
header( 'Content-Type: text/html; charset=utf-8' ); |
| 891 |
header( 'Cache-Control: no-store' ); |
| 892 |
|
| 893 |
$host = (string) wp_parse_url( home_url(), PHP_URL_HOST ); |
| 894 |
$logo = '<svg width="36" height="36" viewBox="0 0 29 29" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">' |
| 895 |
. '<g clip-path="url(#tr_clip0)">' |
| 896 |
. '<path d="M0.436523 6.61665C0.436523 3.15762 3.24063 0.353516 6.69966 0.353516H22.1734C25.6324 0.353516 28.4365 3.15762 28.4365 6.61665V22.0904C28.4365 25.5494 25.6324 28.3535 22.1734 28.3535H6.69966C3.24063 28.3535 0.436523 25.5494 0.436523 22.0904V6.61665Z" fill="url(#tr_p0)"/>' |
| 897 |
. '<path d="M29.1618 8.11914C29.1618 8.11914 29.1622 8.11903 29.3906 8.71865C29.6058 9.28347 29.6182 9.31627 29.6189 9.31817C29.6189 9.31817 29.6185 9.3185 29.6182 9.31862C29.6176 9.31886 29.6166 9.31924 29.6153 9.31976C29.6124 9.32086 29.6078 9.32246 29.6018 9.32477C29.5898 9.32942 29.5714 9.33652 29.5471 9.34596C29.4986 9.36487 29.4265 9.39344 29.3332 9.43073C29.1465 9.50532 28.8754 9.61538 28.5404 9.75703C27.8699 10.0405 26.9452 10.449 25.9291 10.9483C23.8797 11.9554 21.5331 13.2968 20.1165 14.6951C19.2976 15.5641 18.5831 16.4248 17.898 17.2522C17.2153 18.0766 16.555 18.8765 15.8671 19.588C14.477 21.0257 12.9316 22.1479 10.6959 22.53C9.60585 22.7163 8.53683 22.6112 7.52835 22.4516C6.48364 22.2863 5.5662 22.0772 4.59619 21.989C3.82718 21.9191 3.16045 21.9788 2.48024 22.211C1.79208 22.4458 1.05578 22.8689 0.16582 23.5725L-0.629883 22.5658C0.329559 21.8073 1.19422 21.2939 2.06576 20.9965C2.94522 20.6963 3.79701 20.6277 4.7124 20.7109C5.73306 20.8037 6.79732 21.0365 7.7291 21.184C8.69711 21.3372 9.59965 21.4156 10.4799 21.2651C12.3522 20.9451 13.6648 20.0196 14.9444 18.6962C15.5914 18.027 16.2189 17.2678 16.9095 16.4337C17.5953 15.6055 18.3371 14.7115 19.1918 13.8053L19.1994 13.7973L19.2073 13.7893C20.7824 12.2312 23.2976 10.8117 25.3631 9.79668C26.4061 9.28415 27.3538 8.86556 28.0407 8.5751C28.3843 8.4298 28.6633 8.31639 28.8569 8.239C28.9537 8.20031 29.0292 8.17052 29.0809 8.15036C29.1068 8.14028 29.1268 8.13259 29.1404 8.12734C29.1472 8.12472 29.1525 8.12281 29.1561 8.12142C29.1579 8.12073 29.1592 8.11998 29.1602 8.1196C29.1607 8.11941 29.1613 8.11925 29.1616 8.11914H29.1618Z" fill="url(#tr_p2)"/>' |
| 898 |
. '<path d="M22.3437 13.4975C22.3437 14.5285 21.508 15.3642 20.477 15.3642C19.4461 15.3642 18.6104 14.5285 18.6104 13.4975C18.6104 12.4666 19.4461 11.6309 20.477 11.6309C21.508 11.6309 22.3437 12.4666 22.3437 13.4975Z" fill="url(#tr_p3)"/>' |
| 899 |
. '<path d="M20.4766 11.2734C21.7049 11.2734 22.7009 12.2688 22.7012 13.4971C22.7012 14.7256 21.7051 15.7217 20.4766 15.7217C19.2482 15.7214 18.2529 14.7254 18.2529 13.4971C18.2532 12.2689 19.2484 11.2737 20.4766 11.2734Z" stroke="white" stroke-width="0.715555"/>' |
| 900 |
. '<path d="M15.6201 6.78776C15.9391 6.88104 15.9391 7.33291 15.6201 7.42619L13.5873 8.02069C13.4784 8.05254 13.3933 8.13768 13.3614 8.24656L12.7669 10.2794C12.6736 10.5984 12.2218 10.5984 12.1285 10.2794L11.534 8.24656C11.5022 8.13768 11.417 8.05254 11.3081 8.02069L9.27529 7.42619C8.95631 7.33291 8.9563 6.88104 9.27528 6.78776L11.3081 6.19326C11.417 6.16141 11.5022 6.07627 11.534 5.96739L12.1285 3.93455C12.2218 3.61557 12.6736 3.61557 12.7669 3.93455L13.3614 5.96739C13.3933 6.07627 13.4784 6.16141 13.5873 6.19326L15.6201 6.78776Z" fill="url(#tr_p4)"/>' |
| 901 |
. '<path d="M11.6747 13.8907C11.8298 13.9361 11.8298 14.1557 11.6747 14.201L10.4245 14.5667C10.3716 14.5822 10.3302 14.6235 10.3147 14.6765L9.94909 15.9267C9.90375 16.0817 9.68413 16.0817 9.63879 15.9267L9.27317 14.6765C9.25769 14.6235 9.21631 14.5822 9.16339 14.5667L7.91315 14.201C7.75811 14.1557 7.75811 13.9361 7.91315 13.8907L9.16339 13.5251C9.21631 13.5096 9.25769 13.4683 9.27317 13.4153L9.63879 12.1651C9.68413 12.0101 9.90375 12.0101 9.94909 12.1651L10.3147 13.4153C10.3302 13.4683 10.3716 13.5096 10.4245 13.5251L11.6747 13.8907Z" fill="url(#tr_p5)"/>' |
| 902 |
. '</g>' |
| 903 |
. '<rect x="0.353516" y="0.353516" width="28" height="28" rx="7.76216" stroke="#B6CBFF" stroke-width="0.707321"/>' |
| 904 |
. '<defs>' |
| 905 |
. '<linearGradient id="tr_p0" x1="14.4365" y1="0.353516" x2="14.4365" y2="28.3535" gradientUnits="userSpaceOnUse"><stop stop-color="#5A57FF"/><stop offset="1" stop-color="#5B98FF"/></linearGradient>' |
| 906 |
. '<linearGradient id="tr_p2" x1="29.2845" y1="8.77232" x2="0.650625" y2="22.9504" gradientUnits="userSpaceOnUse"><stop stop-color="#BDC1FF"/><stop offset="0.420828" stop-color="#D6E8FF"/><stop offset="1" stop-color="#7391FE"/></linearGradient>' |
| 907 |
. '<radialGradient id="tr_p3" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(22.1572 11.6007) rotate(134.421) scale(4.39003)"><stop offset="0.321315" stop-color="#BAFEFF"/><stop offset="1" stop-color="#011FFF"/></radialGradient>' |
| 908 |
. '<linearGradient id="tr_p4" x1="9.87102" y1="1.28287" x2="14.9766" y2="13.0695" gradientUnits="userSpaceOnUse"><stop stop-color="#FFE6FC"/><stop offset="0.350962" stop-color="#FFE6FC"/><stop offset="1" stop-color="#AA00F2"/></linearGradient>' |
| 909 |
. '<linearGradient id="tr_p5" x1="8.28563" y1="10.6367" x2="11.2743" y2="17.5362" gradientUnits="userSpaceOnUse"><stop stop-color="#FFE6FC"/><stop offset="0.350962" stop-color="#FFE6FC"/><stop offset="1" stop-color="#AA00F2"/></linearGradient>' |
| 910 |
. '<clipPath id="tr_clip0"><rect x="0.353516" y="0.353516" width="28" height="28" rx="7.76216" fill="white"/></clipPath>' |
| 911 |
. '</defs></svg>'; |
| 912 |
$lock = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>'; |
| 913 |
|
| 914 |
echo '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>' . esc_html__( 'Authorize AI access', 'thinkrank' ) . '</title>'; |
| 915 |
echo '<style>' |
| 916 |
. ':root{color-scheme:dark}*{box-sizing:border-box}' |
| 917 |
. 'body{font:15px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:radial-gradient(1100px 600px at 50% -15%,#20284a,#0b1020 62%);color:#e7ecf3;margin:0;display:flex;min-height:100vh;align-items:center;justify-content:center;padding:24px}' |
| 918 |
. '.card{width:100%;max-width:460px;background:#141a2e;border:1px solid #263149;border-radius:20px;padding:28px;box-shadow:0 24px 60px rgba(0,0,0,.5)}' |
| 919 |
. '.brand{display:flex;align-items:center;gap:10px;margin-bottom:22px}' |
| 920 |
. '.logo{width:36px;height:36px;border-radius:11px;display:flex;align-items:center;justify-content:center;box-shadow:0 6px 16px rgba(99,102,241,.4)}' |
| 921 |
. '.brand b{font-size:14px;font-weight:700;letter-spacing:.02em}' |
| 922 |
. 'h1{font-size:20px;font-weight:700;margin:0 0 6px}' |
| 923 |
. '.sub{color:#9aa6be;font-size:13.5px;margin:0 0 22px}.sub strong{color:#e7ecf3;font-weight:600}' |
| 924 |
. '.rows{border:1px solid #263149;border-radius:14px;overflow:hidden;margin-bottom:16px}' |
| 925 |
. '.row{display:flex;justify-content:space-between;align-items:center;gap:16px;padding:13px 16px;font-size:13.5px}' |
| 926 |
. '.row+.row,.access{border-top:1px solid #263149}' |
| 927 |
. '.row .k{color:#9aa6be}.row .v{font-weight:600;text-align:right;word-break:break-word}' |
| 928 |
. '.access{padding:14px 16px;background:rgba(99,102,241,.07)}' |
| 929 |
. '.access .k{color:#9aa6be;font-size:13px;margin-bottom:8px}' |
| 930 |
. '.badge{display:inline-flex;align-items:center;font-size:12px;font-weight:700;padding:3px 10px;border-radius:999px;background:rgba(99,102,241,.18);color:#c7cbff;border:1px solid rgba(99,102,241,.4)}' |
| 931 |
. '.badge.ro{background:rgba(245,158,11,.15);color:#fcd9a1;border-color:rgba(245,158,11,.4)}' |
| 932 |
. '.access .d{color:#c3ccdd;font-size:13px;margin-top:8px}' |
| 933 |
. '.note{display:flex;align-items:center;gap:7px;color:#7f8aa3;font-size:12px;margin:0 0 20px}' |
| 934 |
. '.actions{display:flex;gap:12px}' |
| 935 |
. 'button{flex:1;padding:13px;border-radius:12px;border:0;font-size:14px;font-weight:600;cursor:pointer;transition:filter .15s,transform .05s}button:active{transform:translateY(1px)}' |
| 936 |
. '.approve{background:linear-gradient(135deg,#6366f1,#7c73ff);color:#fff;box-shadow:0 8px 20px rgba(99,102,241,.38)}.approve:hover{filter:brightness(1.07)}' |
| 937 |
. '.deny{background:transparent;color:#aeb8cc;border:1px solid #33405c}.deny:hover{background:rgba(255,255,255,.04)}' |
| 938 |
. '</style></head><body><div class="card">'; |
| 939 |
|
| 940 |
echo '<div class="brand"><span class="logo">' . $logo . '</span><b>ThinkRank</b></div>'; // phpcs:ignore WordPress.Security.EscapeOutput -- static markup. |
| 941 |
echo '<h1>' . esc_html__( 'Connect to ThinkRank', 'thinkrank' ) . '</h1>'; |
| 942 |
$sub = sprintf( |
| 943 |
/* translators: %s: AI client name, already escaped and wrapped in <strong>. */ |
| 944 |
esc_html__( '%s wants to manage SEO on this site.', 'thinkrank' ), |
| 945 |
'<strong>' . esc_html( $client ) . '</strong>' |
| 946 |
); |
| 947 |
echo '<p class="sub">' . $sub . '</p>'; // phpcs:ignore WordPress.Security.EscapeOutput -- static translation; client name esc_html'd. |
| 948 |
|
| 949 |
echo '<div class="rows">'; |
| 950 |
echo '<div class="row"><span class="k">' . esc_html__( 'Site', 'thinkrank' ) . '</span><span class="v">' . esc_html( $host ) . '</span></div>'; |
| 951 |
echo '<div class="row"><span class="k">' . esc_html__( 'Signed in as', 'thinkrank' ) . '</span><span class="v">' . esc_html( $user->user_login ) . '</span></div>'; |
| 952 |
echo '<div class="access"><div class="k">' . esc_html__( 'Access', 'thinkrank' ) . '</div>'; |
| 953 |
echo '<span class="badge ' . ( $read_only ? 'ro' : '' ) . '">' . esc_html( $access_label ) . '</span>'; |
| 954 |
echo '<div class="d">' . esc_html( $access_desc ) . '</div></div>'; |
| 955 |
echo '</div>'; |
| 956 |
|
| 957 |
echo '<p class="note">' . $lock . '<span>' . esc_html__( 'Secured with OAuth. Revoke anytime in ThinkRank → MCP.', 'thinkrank' ) . '</span></p>'; // phpcs:ignore WordPress.Security.EscapeOutput -- static icon; text esc_html'd. |
| 958 |
|
| 959 |
echo '<form method="post" action="' . esc_url( $action_url ) . '">'; |
| 960 |
echo $hidden; // phpcs:ignore WordPress.Security.EscapeOutput -- built from esc_attr() above. |
| 961 |
echo '<input type="hidden" name="_thinkrank_oauth_nonce" value="' . esc_attr( $nonce ) . '" />'; |
| 962 |
echo '<div class="actions">'; |
| 963 |
echo '<button class="deny" name="deny" value="1">' . esc_html__( 'Deny', 'thinkrank' ) . '</button>'; |
| 964 |
echo '<button class="approve" name="approve" value="1">' . esc_html__( 'Approve', 'thinkrank' ) . '</button>'; |
| 965 |
echo '</div></form></div></body></html>'; |
| 966 |
exit; |
| 967 |
} |
| 968 |
|
| 969 |
/** |
| 970 |
* Render a standalone OAuth error page (no redirect). |
| 971 |
* |
| 972 |
* @param string $message Error message. |
| 973 |
* @return void |
| 974 |
*/ |
| 975 |
private function emit_oauth_error_page( string $message ): void { |
| 976 |
status_header( 400 ); |
| 977 |
header( 'Content-Type: text/html; charset=utf-8' ); |
| 978 |
header( 'Cache-Control: no-store' ); |
| 979 |
echo '<!doctype html><html><head><meta charset="utf-8"><title>' . esc_html__( 'Authorization error', 'thinkrank' ) . '</title>'; |
| 980 |
echo '<style>body{font:15px/1.5 -apple-system,sans-serif;background:#0f172a;color:#e2e8f0;display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0}' |
| 981 |
. '.card{background:#1e293b;border:1px solid #334155;border-radius:16px;max-width:440px;padding:32px;text-align:center}</style></head><body>'; |
| 982 |
echo '<div class="card"><h1>' . esc_html__( 'Could not authorize', 'thinkrank' ) . '</h1><p>' . esc_html( $message ) . '</p></div></body></html>'; |
| 983 |
exit; |
| 984 |
} |
| 985 |
|
| 986 |
// -- Helpers -- |
| 987 |
|
| 988 |
/** |
| 989 |
* Read one /authorize parameter verbatim. |
| 990 |
* |
| 991 |
* Deliberately NOT sanitize_text_field(). That function exists to make |
| 992 |
* untrusted text safe to store and display, and part of what it does is |
| 993 |
* strip %XX sequences as an anti-obfuscation measure. Applied to an OAuth |
| 994 |
* protocol value it quietly changes the value's meaning. |
| 995 |
* |
| 996 |
* The concrete failure: registration stores redirect_uris raw from a JSON |
| 997 |
* body, but at /authorize the same URI arrives as a query parameter, so |
| 998 |
* PHP has already URL-decoded it — and sanitising then removed the percent |
| 999 |
* sequences. A client registered with `.../cb?next=%2Fdashboard` was |
| 1000 |
* compared as `.../cb?next=dashboard`, failed the strict match, and was |
| 1001 |
* told `invalid_redirect_uri` for sending exactly what it registered |
| 1002 |
* (#487). `state` has the same problem: it is opaque to us and must |
| 1003 |
* round-trip byte for byte, or the client aborts its own callback. |
| 1004 |
* |
| 1005 |
* Protocol identifiers want validation and rejection, not cleaning. Every |
| 1006 |
* value read here is constrained somewhere better suited to it: |
| 1007 |
* - redirect_uri strict in_array() against the client's registered set |
| 1008 |
* - client_id must resolve to a registered client |
| 1009 |
* - response_type must equal 'code' |
| 1010 |
* - code_challenge_method must equal 'S256' |
| 1011 |
* - code_challenge validated against the RFC 7636 character set |
| 1012 |
* - scope intersected with SUPPORTED_SCOPES |
| 1013 |
* - state opaque; escaped at output (esc_attr / rawurlencode) |
| 1014 |
* - approve/deny tested for emptiness only |
| 1015 |
* - the nonce passed to wp_verify_nonce() |
| 1016 |
* |
| 1017 |
* An array value (`?state[]=x`) reads as absent rather than becoming the |
| 1018 |
* string "Array". |
| 1019 |
* |
| 1020 |
* @since 2.1.0 |
| 1021 |
* |
| 1022 |
* @param array<string,mixed> $source $_GET or $_POST. |
| 1023 |
* @param string $key Parameter name. |
| 1024 |
* @return string |
| 1025 |
*/ |
| 1026 |
private static function oauth_param( array $source, string $key ): string { |
| 1027 |
if ( ! isset( $source[ $key ] ) || ! is_scalar( $source[ $key ] ) ) { |
| 1028 |
return ''; |
| 1029 |
} |
| 1030 |
|
| 1031 |
return (string) wp_unslash( $source[ $key ] ); |
| 1032 |
} |
| 1033 |
|
| 1034 |
/** |
| 1035 |
* Read an inbound HTTP header from $_SERVER (for the pretty path). |
| 1036 |
* |
| 1037 |
* Mirrors WP_REST_Server::get_headers(): on Apache with CGI/FastCGI/suPHP the |
| 1038 |
* Authorization header never lands in HTTP_AUTHORIZATION. WordPress's own |
| 1039 |
* .htaccess passthrough re-publishes it as REDIRECT_HTTP_AUTHORIZATION, and a |
| 1040 |
* few Apache module setups populate neither key but do answer getallheaders(). |
| 1041 |
* The REST route gets this handling from core; the pretty route builds its own |
| 1042 |
* WP_REST_Request, so it has to do the same here or it 401s on those hosts. |
| 1043 |
* |
| 1044 |
* @param string $name Header name. |
| 1045 |
* @return string|null |
| 1046 |
*/ |
| 1047 |
private static function server_header( string $name ): ?string { |
| 1048 |
$key = 'HTTP_' . strtoupper( str_replace( '-', '_', $name ) ); |
| 1049 |
|
| 1050 |
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- token compared constant-time downstream; raw header needed verbatim. |
| 1051 |
if ( isset( $_SERVER[ $key ] ) && '' !== $_SERVER[ $key ] ) { |
| 1052 |
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- as above. |
| 1053 |
return wp_unslash( $_SERVER[ $key ] ); |
| 1054 |
} |
| 1055 |
|
| 1056 |
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- as above. |
| 1057 |
if ( isset( $_SERVER[ 'REDIRECT_' . $key ] ) && '' !== $_SERVER[ 'REDIRECT_' . $key ] ) { |
| 1058 |
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- as above. |
| 1059 |
return wp_unslash( $_SERVER[ 'REDIRECT_' . $key ] ); |
| 1060 |
} |
| 1061 |
|
| 1062 |
if ( function_exists( 'getallheaders' ) ) { |
| 1063 |
$headers = getallheaders(); |
| 1064 |
if ( is_array( $headers ) ) { |
| 1065 |
foreach ( $headers as $header => $value ) { |
| 1066 |
if ( 0 === strcasecmp( (string) $header, $name ) && '' !== (string) $value ) { |
| 1067 |
return (string) $value; |
| 1068 |
} |
| 1069 |
} |
| 1070 |
} |
| 1071 |
} |
| 1072 |
|
| 1073 |
return null; |
| 1074 |
} |
| 1075 |
|
| 1076 |
/** |
| 1077 |
* Emit a WP_REST_Response as a JSON HTTP response and stop. |
| 1078 |
* |
| 1079 |
* @param \WP_REST_Response $response Response to emit. |
| 1080 |
* @return void |
| 1081 |
*/ |
| 1082 |
private function emit_json( \WP_REST_Response $response ): void { |
| 1083 |
status_header( $response->get_status() ); |
| 1084 |
// MCP Streamable HTTP: advertise the protocol version we speak so a |
| 1085 |
// strict client can pin it. We answer JSON (a spec-permitted response |
| 1086 |
// type); we never open an SSE stream, so no session header is needed. |
| 1087 |
header( 'MCP-Protocol-Version: ' . Mcp_Server::PROTOCOL_VERSION ); |
| 1088 |
// Never cached. The pretty endpoint can carry the pairing token in its |
| 1089 |
// path, so a shared cache or proxy holding a response keyed on that URL |
| 1090 |
// would keep an admin-equivalent credential in its store (#396). |
| 1091 |
header( 'Cache-Control: no-store, private' ); |
| 1092 |
// Forward any headers the handler set (notably WWW-Authenticate on a |
| 1093 |
// 401, which drives the OAuth discovery flow). |
| 1094 |
foreach ( $response->get_headers() as $name => $value ) { |
| 1095 |
// Re-assert the status on every header: PHP special-cases |
| 1096 |
// WWW-Authenticate and forces a 401 when no status is given, |
| 1097 |
// which would silently mask the 429 lockout response. |
| 1098 |
header( $name . ': ' . $value, true, $response->get_status() ); |
| 1099 |
} |
| 1100 |
$data = $response->get_data(); |
| 1101 |
if ( null !== $data ) { |
| 1102 |
header( 'Content-Type: application/json; charset=utf-8' ); |
| 1103 |
echo wp_json_encode( $data ); |
| 1104 |
} |
| 1105 |
exit; |
| 1106 |
} |
| 1107 |
} |
| 1108 |
|