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

466 lines 13.4 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,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
106 return [
107 // Decrypted for display and for the self-test's own probe. Stored
108 // encrypted (#396) — a database read on its own no longer yields a
109 // usable admin-equivalent credential.
110 'site_token' => '' === $raw ? '' : Secret_At_Rest::decrypt( $raw ),
111 // What authorize() compares against. Held separately so a token
112 // whose ciphertext can no longer be opened — the auth salt was
113 // rotated, the site was migrated without wp-config — keeps
114 // authenticating the clients already configured with it, instead of
115 // silently locking them out.
116 'token_hash' => isset( $stored['token_hash'] ) ? (string) $stored['token_hash'] : '',
117 'connected' => ! empty( $stored['connected'] ),
118 'connected_at' => isset( $stored['connected_at'] ) ? (int) $stored['connected_at'] : 0,
119 'scopes' => isset( $stored['scopes'] ) && is_array( $stored['scopes'] )
120 ? array_values( array_map( 'strval', $stored['scopes'] ) )
121 : [],
122 'user_id' => isset( $stored['user_id'] ) ? (int) $stored['user_id'] : 0,
123 'last_used' => isset( $stored['last_used'] ) ? (int) $stored['last_used'] : 0,
124 ];
125 }
126
127 /**
128 * Record that the static token was just used to authenticate an MCP call.
129 * Throttled to at most one option write per minute so a busy client can't
130 * turn every request into a database write. No-op when not connected.
131 *
132 * @return void
133 */
134 public static function touch_last_used(): void {
135 $stored = get_option( self::OPTION, [] );
136 if ( ! is_array( $stored ) || empty( $stored['site_token'] ) ) {
137 return;
138 }
139 $now = time();
140 $last = isset( $stored['last_used'] ) ? (int) $stored['last_used'] : 0;
141 if ( $now - $last < self::LAST_USED_THROTTLE ) {
142 return;
143 }
144 $stored['last_used'] = $now;
145 update_option( self::OPTION, $stored, false );
146 }
147
148 /**
149 * SHA-256 used to store the pairing token's verifier at rest.
150 *
151 * Mirrors Mcp_OAuth::hash(), which has always stored access and refresh
152 * tokens this way. The pairing token was the one exception (#396).
153 *
154 * @since 2.0.1
155 *
156 * @param string $value Raw token.
157 * @return string
158 */
159 private static function hash( string $value ): string {
160 return hash( 'sha256', $value );
161 }
162
163 /**
164 * Whether a presented token is the pairing token.
165 *
166 * Compared against the stored hash. A row written before this change holds
167 * a plaintext token and no hash, so it is verified against the plaintext
168 * once and then upgraded in place — an existing pairing keeps working and
169 * no one has to re-pair.
170 *
171 * @since 2.0.1
172 *
173 * @param string $presented Token presented by the client.
174 * @return bool
175 */
176 public static function verify_token( string $presented ): bool {
177 if ( '' === $presented ) {
178 return false;
179 }
180
181 $state = self::state();
182
183 if ( '' !== $state['token_hash'] ) {
184 return hash_equals( $state['token_hash'], self::hash( $presented ) );
185 }
186
187 // Legacy row: plaintext, no hash.
188 if ( '' === $state['site_token'] || ! hash_equals( $state['site_token'], $presented ) ) {
189 return false;
190 }
191
192 self::upgrade_legacy_storage( $presented );
193
194 return true;
195 }
196
197 /**
198 * Re-store a legacy plaintext token encrypted, with its hash.
199 *
200 * @since 2.0.1
201 *
202 * @param string $token Raw token, already verified.
203 * @return void
204 */
205 private static function upgrade_legacy_storage( string $token ): void {
206 $stored = get_option( self::OPTION, [] );
207
208 if ( ! is_array( $stored ) ) {
209 return;
210 }
211
212 $stored['site_token'] = Secret_At_Rest::encrypt( $token );
213 $stored['token_hash'] = self::hash( $token );
214
215 update_option( self::OPTION, $stored, false );
216 }
217
218 /**
219 * The stored site token (secret). Empty string when not connected.
220 *
221 * @return string
222 */
223 public static function site_token(): string {
224 return self::state()['site_token'];
225 }
226
227 /**
228 * The admin user the connection runs as (the token's minter).
229 *
230 * @return int
231 */
232 public static function user_id(): int {
233 return self::state()['user_id'];
234 }
235
236 /**
237 * Whether an MCP connection token is currently active for this site.
238 *
239 * @return bool
240 */
241 public static function is_connected(): bool {
242 $state = self::state();
243 return $state['connected'] && '' !== $state['site_token'];
244 }
245
246 /**
247 * Whether the active connection is limited to read-only tools.
248 *
249 * @return bool
250 */
251 public static function is_read_only(): bool {
252 $scopes = self::state()['scopes'];
253 return ! in_array( 'write', $scopes, true );
254 }
255
256 /**
257 * Sanitized snapshot for the MCP admin page.
258 *
259 * @return array<string,mixed>
260 */
261 public static function public_status(): array {
262 $state = self::state();
263 return [
264 'connected' => self::is_connected(),
265 'connection_token' => $state['site_token'],
266 'connect_url' => self::connect_url(),
267 'mcp_endpoint' => self::site_endpoint(),
268 'mcp_endpoint_rest' => self::site_endpoint_fallback(),
269 'connected_at' => $state['connected_at'],
270 'last_used' => $state['last_used'],
271 'scopes' => $state['scopes'],
272 'read_only' => self::is_read_only(),
273 // Ready-to-paste connection recipes (header-based — token stays out
274 // of the URL, so it can't leak into server/proxy logs).
275 'config' => self::config_snippets(),
276 // A drop-in instruction the user can paste into their AI client so
277 // it sets the connection up itself.
278 'ai_prompt' => self::ai_prompt(),
279 ];
280 }
281
282 /**
283 * Ready-to-paste connection recipes for the dashboard. All header-based
284 * (Authorization: Bearer) so the secret stays out of URLs and logs.
285 * Empty strings when not connected.
286 *
287 * @return array{cli:string,json:string}
288 */
289 public static function config_snippets(): array {
290 $token = self::site_token();
291 if ( '' === $token ) {
292 return [
293 'cli' => '',
294 'json' => '',
295 ];
296 }
297 $endpoint = self::site_endpoint();
298
299 // Claude Code one-liner. The CLI requires the positional NAME and URL
300 // BEFORE any flags (`claude mcp add <name> <url> --flags`).
301 $cli = sprintf(
302 'claude mcp add thinkrank %s --transport http --header "Authorization: Bearer %s"',
303 $endpoint,
304 $token
305 );
306
307 // Portable mcpServers JSON block (Claude Desktop / other clients).
308 $json = wp_json_encode(
309 [
310 'mcpServers' => [
311 'thinkrank' => [
312 'url' => $endpoint,
313 'headers' => [
314 'Authorization' => 'Bearer ' . $token,
315 ],
316 ],
317 ],
318 ],
319 JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
320 );
321
322 return [
323 'cli' => $cli,
324 'json' => is_string( $json ) ? $json : '',
325 ];
326 }
327
328 /**
329 * A copy-paste instruction the user hands to their AI assistant so IT
330 * sets up the ThinkRank MCP connection for them. Empty when not connected.
331 *
332 * @return string
333 */
334 public static function ai_prompt(): string {
335 if ( ! self::is_connected() ) {
336 return '';
337 }
338 $token = self::site_token();
339 $endpoint = self::site_endpoint();
340 $access = self::is_read_only()
341 ? 'read-only (inspect SEO data and settings only)'
342 : 'read-write (can update SEO metadata and settings)';
343
344 $lines = [
345 'Add the following remote MCP server to your connections so you can manage SEO on my WordPress site, then use it.',
346 '',
347 'Server name: ThinkRank',
348 'Server URL: ' . $endpoint,
349 'Transport: streamable HTTP',
350 'Authentication: Bearer token (in the Authorization header)',
351 'API key: ' . $token,
352 'Access level: ' . $access,
353 '',
354 'If you use the Claude Code CLI, this is the exact command (name and URL come BEFORE the flags):',
355 ' ' . self::config_snippets()['cli'],
356 '',
357 'Add it now, confirm it is connected by calling its "list-content-types" tool, and tell me what content types you can see.',
358 ];
359 $prompt = implode( "\n", $lines );
360
361 /**
362 * Filter the copy-paste AI setup prompt shown in the MCP page.
363 *
364 * @param string $prompt The default prompt text.
365 * @param bool $read_only Whether the connection is read-only.
366 */
367 return (string) apply_filters( 'thinkrank_mcp_ai_prompt', $prompt, self::is_read_only() );
368 }
369
370 /**
371 * Connect — mint a connection token for this site's MCP endpoint.
372 *
373 * Idempotent: re-connecting keeps the existing token (and its scopes) so
374 * a paired client isn't silently broken. Use rotate() to change either.
375 *
376 * @param bool $read_only Grant only the `read` scope on a NEW token.
377 * @return array<string,mixed> Public status.
378 */
379 public static function connect( bool $read_only = false ): array {
380 $state = self::state();
381 $existing = '' !== $state['site_token'];
382 $token = $existing ? $state['site_token'] : self::mint_token();
383 $scopes = $existing && ! empty( $state['scopes'] )
384 ? $state['scopes']
385 : self::scopes_for( $read_only );
386
387 update_option(
388 self::OPTION,
389 [
390 'site_token' => Secret_At_Rest::encrypt( $token ),
391 'token_hash' => self::hash( $token ),
392 'connected' => true,
393 'connected_at' => $existing ? $state['connected_at'] : time(),
394 'scopes' => $scopes,
395 'user_id' => $existing && $state['user_id'] ? $state['user_id'] : get_current_user_id(),
396 ],
397 false
398 );
399
400 return self::public_status();
401 }
402
403 /**
404 * Rotate — mint a BRAND-NEW token, invalidating the previous one
405 * immediately. The leaked-token remedy. Optionally flips read-only.
406 *
407 * @param bool|null $read_only null = keep current scopes; true/false = set.
408 * @return array<string,mixed> Public status with the fresh token.
409 */
410 public static function rotate( ?bool $read_only = null ): array {
411 $state = self::state();
412 $scopes = null === $read_only
413 ? ( ! empty( $state['scopes'] ) ? $state['scopes'] : self::DEFAULT_SCOPES )
414 : self::scopes_for( $read_only );
415
416 $token = self::mint_token();
417
418 update_option(
419 self::OPTION,
420 [
421 'site_token' => Secret_At_Rest::encrypt( $token ),
422 'token_hash' => self::hash( $token ),
423 'connected' => true,
424 'connected_at' => time(),
425 'scopes' => $scopes,
426 'user_id' => get_current_user_id() ? get_current_user_id() : $state['user_id'],
427 ],
428 false
429 );
430
431 return self::public_status();
432 }
433
434 /**
435 * Disconnect — revoke the connection token AND every OAuth grant, so
436 * Disconnect is a single kill switch for ALL MCP access.
437 *
438 * @return array<string,mixed> Public status after disconnect.
439 */
440 public static function disconnect(): array {
441 delete_option( self::OPTION );
442 Mcp_OAuth::revoke_all();
443
444 return self::public_status();
445 }
446
447 /**
448 * Map a read-only flag to the granted scope list.
449 *
450 * @param bool $read_only Whether to grant read-only access.
451 * @return string[]
452 */
453 private static function scopes_for( bool $read_only ): array {
454 return $read_only ? [ 'read' ] : self::DEFAULT_SCOPES;
455 }
456
457 /**
458 * Mint a 32-byte random token (64 hex chars).
459 *
460 * @return string
461 */
462 private static function mint_token(): string {
463 return bin2hex( random_bytes( 32 ) );
464 }
465 }
466