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

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

512 lines 15.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * MCP pairing lifecycle — mint / rotate / revoke the per-site connection token.
4 *
5 * The primary way an AI assistant connects to ThinkRank: an admin clicks
6 * Connect, the plugin mints a 32-byte secret, and the user pastes either the
7 * single connect URL (token embedded in the path) or the endpoint + Bearer
8 * token into their AI client. The token is validated directly by Mcp_Server —
9 * no hosted infrastructure is involved.
10 *
11 * State is stored in the `thinkrank_mcp_pairing` option:
12 * {
13 * site_token: string (the secret the client presents),
14 * connected: bool,
15 * connected_at: int (unix ts),
16 * scopes: string[] (e.g. ['read','write']),
17 * user_id: int (admin who minted the token; MCP calls run as them)
18 * }
19 *
20 * @package ThinkRank\Mcp
21 */
22
23 declare(strict_types=1);
24
25 namespace ThinkRank\Mcp;
26
27 use ThinkRank\Core\Secret_At_Rest;
28
29 if ( ! defined( 'ABSPATH' ) ) {
30 exit; // Exit if accessed directly.
31 }
32
33 /**
34 * Connection-token lifecycle for the ThinkRank MCP server.
35 */
36 final class Mcp_Pairing {
37
38 /**
39 * Option key holding all MCP pairing state.
40 */
41 public const OPTION = 'thinkrank_mcp_pairing';
42
43 /**
44 * Path segment of the pretty per-site endpoint.
45 */
46 public const SITE_ENDPOINT_PATH = 'thinkrank/mcp';
47
48 /**
49 * Default scopes granted on connect.
50 */
51 private const DEFAULT_SCOPES = [ 'read', 'write' ];
52
53 /**
54 * Throttle window (seconds) for last-used writes — one option write per
55 * minute at most, so a busy client can't hammer the option on every call.
56 */
57 private const LAST_USED_THROTTLE = 60;
58
59 /**
60 * The PRIMARY endpoint the user pastes into their AI client — this
61 * site's own MCP URL.
62 *
63 * @return string
64 */
65 public static function site_endpoint(): string {
66 return home_url( '/' . self::SITE_ENDPOINT_PATH );
67 }
68
69 /**
70 * Always-on fallback endpoint via the REST namespace, for hosts where
71 * the pretty rewrite can't be served (e.g. plain permalinks).
72 *
73 * @return string
74 */
75 public static function site_endpoint_fallback(): string {
76 return rest_url( 'thinkrank/v1/mcp' );
77 }
78
79 /**
80 * The SINGLE URL the user pastes into their AI client — the pretty
81 * endpoint with the connection token embedded as a path segment.
82 * Empty string when not connected.
83 *
84 * @return string
85 */
86 public static function connect_url(): string {
87 $token = self::site_token();
88 if ( '' === $token ) {
89 return '';
90 }
91 return self::site_endpoint() . '/' . $token;
92 }
93
94 /**
95 * Current pairing state, defaults merged.
96 *
97 * @return array{site_token:string,token_hash:string,token_sealed:bool,connected:bool,connected_at:int,scopes:string[],user_id:int,last_used:int}
98 */
99 public static function state(): array {
100 $stored = get_option( self::OPTION, [] );
101 if ( ! is_array( $stored ) ) {
102 $stored = [];
103 }
104 $raw = isset( $stored['site_token'] ) ? (string) $stored['site_token'] : '';
105 $plain = '' === $raw ? '' : Secret_At_Rest::decrypt( $raw );
106
107 // Sealed: something IS stored, but this site can no longer open it —
108 // the auth salt rotated, or sodium went away under us (decrypt() hands
109 // the envelope back unchanged in that case). Either way there is no
110 // displayable credential, and the envelope must never be passed off as
111 // one: it would be copied into a client and 401 forever.
112 $sealed = '' !== $raw && ( '' === $plain || Secret_At_Rest::is_encrypted( $plain ) );
113
114 return [
115 // Decrypted for display and for the self-test's own probe. Stored
116 // encrypted (#396) — a database read on its own no longer yields a
117 // usable admin-equivalent credential.
118 'site_token' => $sealed ? '' : $plain,
119 // Whether a stored token exists that cannot be shown here. Callers
120 // use this to tell "never connected" apart from "connected, but
121 // this site cannot display the token any more".
122 'token_sealed' => $sealed,
123 // What authorize() compares against. Held separately so a token
124 // whose ciphertext can no longer be opened — the auth salt was
125 // rotated, the site was migrated without wp-config — keeps
126 // authenticating the clients already configured with it, instead of
127 // silently locking them out.
128 'token_hash' => isset( $stored['token_hash'] ) ? (string) $stored['token_hash'] : '',
129 'connected' => ! empty( $stored['connected'] ),
130 'connected_at' => isset( $stored['connected_at'] ) ? (int) $stored['connected_at'] : 0,
131 'scopes' => isset( $stored['scopes'] ) && is_array( $stored['scopes'] )
132 ? array_values( array_map( 'strval', $stored['scopes'] ) )
133 : [],
134 'user_id' => isset( $stored['user_id'] ) ? (int) $stored['user_id'] : 0,
135 'last_used' => isset( $stored['last_used'] ) ? (int) $stored['last_used'] : 0,
136 ];
137 }
138
139 /**
140 * Record that the static token was just used to authenticate an MCP call.
141 * Throttled to at most one option write per minute so a busy client can't
142 * turn every request into a database write. No-op when not connected.
143 *
144 * @return void
145 */
146 public static function touch_last_used(): void {
147 $stored = get_option( self::OPTION, [] );
148 if ( ! is_array( $stored ) || empty( $stored['site_token'] ) ) {
149 return;
150 }
151 $now = time();
152 $last = isset( $stored['last_used'] ) ? (int) $stored['last_used'] : 0;
153 if ( $now - $last < self::LAST_USED_THROTTLE ) {
154 return;
155 }
156 $stored['last_used'] = $now;
157 update_option( self::OPTION, $stored, false );
158 }
159
160 /**
161 * SHA-256 used to store the pairing token's verifier at rest.
162 *
163 * Mirrors Mcp_OAuth::hash(), which has always stored access and refresh
164 * tokens this way. The pairing token was the one exception (#396).
165 *
166 * @since 2.0.1
167 *
168 * @param string $value Raw token.
169 * @return string
170 */
171 private static function hash( string $value ): string {
172 return hash( 'sha256', $value );
173 }
174
175 /**
176 * Whether a presented token is the pairing token.
177 *
178 * Compared against the stored hash. A row written before this change holds
179 * a plaintext token and no hash, so it is verified against the plaintext
180 * once and then upgraded in place — an existing pairing keeps working and
181 * no one has to re-pair.
182 *
183 * @since 2.0.1
184 *
185 * @param string $presented Token presented by the client.
186 * @return bool
187 */
188 public static function verify_token( string $presented ): bool {
189 if ( '' === $presented ) {
190 return false;
191 }
192
193 $state = self::state();
194
195 if ( '' !== $state['token_hash'] ) {
196 return hash_equals( $state['token_hash'], self::hash( $presented ) );
197 }
198
199 // Legacy row: plaintext, no hash.
200 if ( '' === $state['site_token'] || ! hash_equals( $state['site_token'], $presented ) ) {
201 return false;
202 }
203
204 self::upgrade_legacy_storage( $presented );
205
206 return true;
207 }
208
209 /**
210 * Re-store a legacy plaintext token encrypted, with its hash.
211 *
212 * @since 2.0.1
213 *
214 * @param string $token Raw token, already verified.
215 * @return void
216 */
217 private static function upgrade_legacy_storage( string $token ): void {
218 $stored = get_option( self::OPTION, [] );
219
220 if ( ! is_array( $stored ) ) {
221 return;
222 }
223
224 $stored['site_token'] = Secret_At_Rest::encrypt( $token );
225 $stored['token_hash'] = self::hash( $token );
226
227 update_option( self::OPTION, $stored, false );
228 }
229
230 /**
231 * The stored site token (secret). Empty string when not connected.
232 *
233 * @return string
234 */
235 public static function site_token(): string {
236 return self::state()['site_token'];
237 }
238
239 /**
240 * The admin user the connection runs as (the token's minter).
241 *
242 * @return int
243 */
244 public static function user_id(): int {
245 return self::state()['user_id'];
246 }
247
248 /**
249 * Whether an MCP connection token is currently active for this site.
250 *
251 * Deliberately reads the hash, not the decrypted token. Those are not the
252 * same question: after an auth salt rotation the ciphertext will not open,
253 * so `site_token` is '' — but `token_hash` still verifies the credential
254 * every configured client is holding, and verify_token() still accepts it.
255 * Answering "not connected" there made ensure_connected() mint a fresh
256 * token over the hash, which was the only surviving copy of the live one.
257 *
258 * @return bool
259 */
260 public static function is_connected(): bool {
261 $state = self::state();
262 return $state['connected'] && ( '' !== $state['token_hash'] || '' !== $state['site_token'] );
263 }
264
265 /**
266 * Whether the active connection is limited to read-only tools.
267 *
268 * @return bool
269 */
270 public static function is_read_only(): bool {
271 $scopes = self::state()['scopes'];
272 return ! in_array( 'write', $scopes, true );
273 }
274
275 /**
276 * Sanitized snapshot for the MCP admin page.
277 *
278 * @return array<string,mixed>
279 */
280 public static function public_status(): array {
281 $state = self::state();
282 return [
283 'connected' => self::is_connected(),
284 'connection_token' => $state['site_token'],
285 // Connected, but the token cannot be displayed on this site any
286 // more. The screen offers a rotate instead of a blank recipe.
287 'token_sealed' => $state['token_sealed'],
288 'connect_url' => self::connect_url(),
289 'mcp_endpoint' => self::site_endpoint(),
290 'mcp_endpoint_rest' => self::site_endpoint_fallback(),
291 'connected_at' => $state['connected_at'],
292 'last_used' => $state['last_used'],
293 'scopes' => $state['scopes'],
294 'read_only' => self::is_read_only(),
295 // Ready-to-paste connection recipes (header-based — token stays out
296 // of the URL, so it can't leak into server/proxy logs).
297 'config' => self::config_snippets(),
298 // A drop-in instruction the user can paste into their AI client so
299 // it sets the connection up itself.
300 'ai_prompt' => self::ai_prompt(),
301 ];
302 }
303
304 /**
305 * Ready-to-paste connection recipes for the dashboard. All header-based
306 * (Authorization: Bearer) so the secret stays out of URLs and logs.
307 * Empty strings when not connected.
308 *
309 * @return array{cli:string,json:string}
310 */
311 public static function config_snippets(): array {
312 $token = self::site_token();
313 if ( '' === $token ) {
314 return [
315 'cli' => '',
316 'json' => '',
317 ];
318 }
319 $endpoint = self::site_endpoint();
320
321 // Claude Code one-liner. The CLI requires the positional NAME and URL
322 // BEFORE any flags (`claude mcp add <name> <url> --flags`).
323 $cli = sprintf(
324 'claude mcp add thinkrank %s --transport http --header "Authorization: Bearer %s"',
325 $endpoint,
326 $token
327 );
328
329 // Portable mcpServers JSON block (Claude Desktop / other clients).
330 $json = wp_json_encode(
331 [
332 'mcpServers' => [
333 'thinkrank' => [
334 'url' => $endpoint,
335 'headers' => [
336 'Authorization' => 'Bearer ' . $token,
337 ],
338 ],
339 ],
340 ],
341 JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
342 );
343
344 return [
345 'cli' => $cli,
346 'json' => is_string( $json ) ? $json : '',
347 ];
348 }
349
350 /**
351 * A copy-paste instruction the user hands to their AI assistant so IT
352 * sets up the ThinkRank MCP connection for them. Empty when not connected.
353 *
354 * @return string
355 */
356 public static function ai_prompt(): string {
357 if ( ! self::is_connected() ) {
358 return '';
359 }
360 $token = self::site_token();
361 $endpoint = self::site_endpoint();
362 $access = self::is_read_only()
363 ? 'read-only (inspect SEO data and settings only)'
364 : 'read-write (can update SEO metadata and settings)';
365
366 $lines = [
367 'Add the following remote MCP server to your connections so you can manage SEO on my WordPress site, then use it.',
368 '',
369 'Server name: ThinkRank',
370 'Server URL: ' . $endpoint,
371 'Transport: streamable HTTP',
372 'Authentication: Bearer token (in the Authorization header)',
373 'API key: ' . $token,
374 'Access level: ' . $access,
375 '',
376 'If you use the Claude Code CLI, this is the exact command (name and URL come BEFORE the flags):',
377 ' ' . self::config_snippets()['cli'],
378 '',
379 'Add it now, confirm it is connected by calling its "list-content-types" tool, and tell me what content types you can see.',
380 ];
381 $prompt = implode( "\n", $lines );
382
383 /**
384 * Filter the copy-paste AI setup prompt shown in the MCP page.
385 *
386 * @param string $prompt The default prompt text.
387 * @param bool $read_only Whether the connection is read-only.
388 */
389 return (string) apply_filters( 'thinkrank_mcp_ai_prompt', $prompt, self::is_read_only() );
390 }
391
392 /**
393 * Connect — mint a connection token for this site's MCP endpoint.
394 *
395 * Idempotent: re-connecting keeps the existing token (and its scopes) so
396 * a paired client isn't silently broken. Use rotate() to change either.
397 *
398 * @param bool $read_only Grant only the `read` scope on a NEW token.
399 * @return array<string,mixed> Public status.
400 */
401 public static function connect( bool $read_only = false ): array {
402 $state = self::state();
403 // The hash is what decides "is there a pairing", not the decrypted
404 // token: after an auth salt rotation the ciphertext will not open, but
405 // the credential every configured client holds still authenticates
406 // against the hash.
407 $existing = '' !== $state['token_hash'] || '' !== $state['site_token'];
408 $scopes = $existing && ! empty( $state['scopes'] )
409 ? $state['scopes']
410 : self::scopes_for( $read_only );
411
412 if ( $existing && $state['token_sealed'] ) {
413 // Keeping a pairing this site can no longer read. Falling through
414 // would re-encrypt $state['site_token'] — which is '' here — and
415 // write hash('') over token_hash, destroying the last copy of a
416 // live credential and silently resetting its scopes and owner.
417 // Touch only the metadata; rotate() is the deliberate re-mint.
418 $stored = get_option( self::OPTION, [] );
419 $stored = is_array( $stored ) ? $stored : [];
420 $stored['connected'] = true;
421 $stored['scopes'] = $scopes;
422 if ( empty( $stored['user_id'] ) ) {
423 $stored['user_id'] = get_current_user_id();
424 }
425
426 update_option( self::OPTION, $stored, false );
427
428 return self::public_status();
429 }
430
431 $token = $existing ? $state['site_token'] : self::mint_token();
432
433 update_option(
434 self::OPTION,
435 [
436 'site_token' => Secret_At_Rest::encrypt( $token ),
437 'token_hash' => self::hash( $token ),
438 'connected' => true,
439 'connected_at' => $existing ? $state['connected_at'] : time(),
440 'scopes' => $scopes,
441 'user_id' => $existing && $state['user_id'] ? $state['user_id'] : get_current_user_id(),
442 ],
443 false
444 );
445
446 return self::public_status();
447 }
448
449 /**
450 * Rotate — mint a BRAND-NEW token, invalidating the previous one
451 * immediately. The leaked-token remedy. Optionally flips read-only.
452 *
453 * @param bool|null $read_only null = keep current scopes; true/false = set.
454 * @return array<string,mixed> Public status with the fresh token.
455 */
456 public static function rotate( ?bool $read_only = null ): array {
457 $state = self::state();
458 $scopes = null === $read_only
459 ? ( ! empty( $state['scopes'] ) ? $state['scopes'] : self::DEFAULT_SCOPES )
460 : self::scopes_for( $read_only );
461
462 $token = self::mint_token();
463
464 update_option(
465 self::OPTION,
466 [
467 'site_token' => Secret_At_Rest::encrypt( $token ),
468 'token_hash' => self::hash( $token ),
469 'connected' => true,
470 'connected_at' => time(),
471 'scopes' => $scopes,
472 'user_id' => get_current_user_id() ? get_current_user_id() : $state['user_id'],
473 ],
474 false
475 );
476
477 return self::public_status();
478 }
479
480 /**
481 * Disconnect — revoke the connection token AND every OAuth grant, so
482 * Disconnect is a single kill switch for ALL MCP access.
483 *
484 * @return array<string,mixed> Public status after disconnect.
485 */
486 public static function disconnect(): array {
487 delete_option( self::OPTION );
488 Mcp_OAuth::revoke_all();
489
490 return self::public_status();
491 }
492
493 /**
494 * Map a read-only flag to the granted scope list.
495 *
496 * @param bool $read_only Whether to grant read-only access.
497 * @return string[]
498 */
499 private static function scopes_for( bool $read_only ): array {
500 return $read_only ? [ 'read' ] : self::DEFAULT_SCOPES;
501 }
502
503 /**
504 * Mint a 32-byte random token (64 hex chars).
505 *
506 * @return string
507 */
508 private static function mint_token(): string {
509 return bin2hex( random_bytes( 32 ) );
510 }
511 }
512