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

379 lines 10.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 if ( ! defined( 'ABSPATH' ) ) {
28 exit; // Exit if accessed directly.
29 }
30
31 /**
32 * Connection-token lifecycle for the ThinkRank MCP server.
33 */
34 final class Mcp_Pairing {
35
36 /**
37 * Option key holding all MCP pairing state.
38 */
39 public const OPTION = 'thinkrank_mcp_pairing';
40
41 /**
42 * Path segment of the pretty per-site endpoint.
43 */
44 public const SITE_ENDPOINT_PATH = 'thinkrank/mcp';
45
46 /**
47 * Default scopes granted on connect.
48 */
49 private const DEFAULT_SCOPES = [ 'read', 'write' ];
50
51 /**
52 * Throttle window (seconds) for last-used writes — one option write per
53 * minute at most, so a busy client can't hammer the option on every call.
54 */
55 private const LAST_USED_THROTTLE = 60;
56
57 /**
58 * The PRIMARY endpoint the user pastes into their AI client — this
59 * site's own MCP URL.
60 *
61 * @return string
62 */
63 public static function site_endpoint(): string {
64 return home_url( '/' . self::SITE_ENDPOINT_PATH );
65 }
66
67 /**
68 * Always-on fallback endpoint via the REST namespace, for hosts where
69 * the pretty rewrite can't be served (e.g. plain permalinks).
70 *
71 * @return string
72 */
73 public static function site_endpoint_fallback(): string {
74 return rest_url( 'thinkrank/v1/mcp' );
75 }
76
77 /**
78 * The SINGLE URL the user pastes into their AI client — the pretty
79 * endpoint with the connection token embedded as a path segment.
80 * Empty string when not connected.
81 *
82 * @return string
83 */
84 public static function connect_url(): string {
85 $token = self::site_token();
86 if ( '' === $token ) {
87 return '';
88 }
89 return self::site_endpoint() . '/' . $token;
90 }
91
92 /**
93 * Current pairing state, defaults merged.
94 *
95 * @return array{site_token:string,connected:bool,connected_at:int,scopes:string[],user_id:int,last_used:int}
96 */
97 public static function state(): array {
98 $stored = get_option( self::OPTION, [] );
99 if ( ! is_array( $stored ) ) {
100 $stored = [];
101 }
102 return [
103 'site_token' => isset( $stored['site_token'] ) ? (string) $stored['site_token'] : '',
104 'connected' => ! empty( $stored['connected'] ),
105 'connected_at' => isset( $stored['connected_at'] ) ? (int) $stored['connected_at'] : 0,
106 'scopes' => isset( $stored['scopes'] ) && is_array( $stored['scopes'] )
107 ? array_values( array_map( 'strval', $stored['scopes'] ) )
108 : [],
109 'user_id' => isset( $stored['user_id'] ) ? (int) $stored['user_id'] : 0,
110 'last_used' => isset( $stored['last_used'] ) ? (int) $stored['last_used'] : 0,
111 ];
112 }
113
114 /**
115 * Record that the static token was just used to authenticate an MCP call.
116 * Throttled to at most one option write per minute so a busy client can't
117 * turn every request into a database write. No-op when not connected.
118 *
119 * @return void
120 */
121 public static function touch_last_used(): void {
122 $stored = get_option( self::OPTION, [] );
123 if ( ! is_array( $stored ) || empty( $stored['site_token'] ) ) {
124 return;
125 }
126 $now = time();
127 $last = isset( $stored['last_used'] ) ? (int) $stored['last_used'] : 0;
128 if ( $now - $last < self::LAST_USED_THROTTLE ) {
129 return;
130 }
131 $stored['last_used'] = $now;
132 update_option( self::OPTION, $stored, false );
133 }
134
135 /**
136 * The stored site token (secret). Empty string when not connected.
137 *
138 * @return string
139 */
140 public static function site_token(): string {
141 return self::state()['site_token'];
142 }
143
144 /**
145 * The admin user the connection runs as (the token's minter).
146 *
147 * @return int
148 */
149 public static function user_id(): int {
150 return self::state()['user_id'];
151 }
152
153 /**
154 * Whether an MCP connection token is currently active for this site.
155 *
156 * @return bool
157 */
158 public static function is_connected(): bool {
159 $state = self::state();
160 return $state['connected'] && '' !== $state['site_token'];
161 }
162
163 /**
164 * Whether the active connection is limited to read-only tools.
165 *
166 * @return bool
167 */
168 public static function is_read_only(): bool {
169 $scopes = self::state()['scopes'];
170 return ! in_array( 'write', $scopes, true );
171 }
172
173 /**
174 * Sanitized snapshot for the MCP admin page.
175 *
176 * @return array<string,mixed>
177 */
178 public static function public_status(): array {
179 $state = self::state();
180 return [
181 'connected' => self::is_connected(),
182 'connection_token' => $state['site_token'],
183 'connect_url' => self::connect_url(),
184 'mcp_endpoint' => self::site_endpoint(),
185 'mcp_endpoint_rest' => self::site_endpoint_fallback(),
186 'connected_at' => $state['connected_at'],
187 'last_used' => $state['last_used'],
188 'scopes' => $state['scopes'],
189 'read_only' => self::is_read_only(),
190 // Ready-to-paste connection recipes (header-based — token stays out
191 // of the URL, so it can't leak into server/proxy logs).
192 'config' => self::config_snippets(),
193 // A drop-in instruction the user can paste into their AI client so
194 // it sets the connection up itself.
195 'ai_prompt' => self::ai_prompt(),
196 ];
197 }
198
199 /**
200 * Ready-to-paste connection recipes for the dashboard. All header-based
201 * (Authorization: Bearer) so the secret stays out of URLs and logs.
202 * Empty strings when not connected.
203 *
204 * @return array{cli:string,json:string}
205 */
206 public static function config_snippets(): array {
207 $token = self::site_token();
208 if ( '' === $token ) {
209 return [
210 'cli' => '',
211 'json' => '',
212 ];
213 }
214 $endpoint = self::site_endpoint();
215
216 // Claude Code one-liner. The CLI requires the positional NAME and URL
217 // BEFORE any flags (`claude mcp add <name> <url> --flags`).
218 $cli = sprintf(
219 'claude mcp add thinkrank %s --transport http --header "Authorization: Bearer %s"',
220 $endpoint,
221 $token
222 );
223
224 // Portable mcpServers JSON block (Claude Desktop / other clients).
225 $json = wp_json_encode(
226 [
227 'mcpServers' => [
228 'thinkrank' => [
229 'url' => $endpoint,
230 'headers' => [
231 'Authorization' => 'Bearer ' . $token,
232 ],
233 ],
234 ],
235 ],
236 JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES
237 );
238
239 return [
240 'cli' => $cli,
241 'json' => is_string( $json ) ? $json : '',
242 ];
243 }
244
245 /**
246 * A copy-paste instruction the user hands to their AI assistant so IT
247 * sets up the ThinkRank MCP connection for them. Empty when not connected.
248 *
249 * @return string
250 */
251 public static function ai_prompt(): string {
252 if ( ! self::is_connected() ) {
253 return '';
254 }
255 $token = self::site_token();
256 $endpoint = self::site_endpoint();
257 $access = self::is_read_only()
258 ? 'read-only (inspect SEO data and settings only)'
259 : 'read-write (can update SEO metadata and settings)';
260
261 $lines = [
262 'Add the following remote MCP server to your connections so you can manage SEO on my WordPress site, then use it.',
263 '',
264 'Server name: ThinkRank',
265 'Server URL: ' . $endpoint,
266 'Transport: streamable HTTP',
267 'Authentication: Bearer token (in the Authorization header)',
268 'API key: ' . $token,
269 'Access level: ' . $access,
270 '',
271 'If you use the Claude Code CLI, this is the exact command (name and URL come BEFORE the flags):',
272 ' ' . self::config_snippets()['cli'],
273 '',
274 'Add it now, confirm it is connected by calling its "list-content-types" tool, and tell me what content types you can see.',
275 ];
276 $prompt = implode( "\n", $lines );
277
278 /**
279 * Filter the copy-paste AI setup prompt shown in the MCP page.
280 *
281 * @param string $prompt The default prompt text.
282 * @param bool $read_only Whether the connection is read-only.
283 */
284 return (string) apply_filters( 'thinkrank_mcp_ai_prompt', $prompt, self::is_read_only() );
285 }
286
287 /**
288 * Connect — mint a connection token for this site's MCP endpoint.
289 *
290 * Idempotent: re-connecting keeps the existing token (and its scopes) so
291 * a paired client isn't silently broken. Use rotate() to change either.
292 *
293 * @param bool $read_only Grant only the `read` scope on a NEW token.
294 * @return array<string,mixed> Public status.
295 */
296 public static function connect( bool $read_only = false ): array {
297 $state = self::state();
298 $existing = '' !== $state['site_token'];
299 $token = $existing ? $state['site_token'] : self::mint_token();
300 $scopes = $existing && ! empty( $state['scopes'] )
301 ? $state['scopes']
302 : self::scopes_for( $read_only );
303
304 update_option(
305 self::OPTION,
306 [
307 'site_token' => $token,
308 'connected' => true,
309 'connected_at' => $existing ? $state['connected_at'] : time(),
310 'scopes' => $scopes,
311 'user_id' => $existing && $state['user_id'] ? $state['user_id'] : get_current_user_id(),
312 ],
313 false
314 );
315
316 return self::public_status();
317 }
318
319 /**
320 * Rotate — mint a BRAND-NEW token, invalidating the previous one
321 * immediately. The leaked-token remedy. Optionally flips read-only.
322 *
323 * @param bool|null $read_only null = keep current scopes; true/false = set.
324 * @return array<string,mixed> Public status with the fresh token.
325 */
326 public static function rotate( ?bool $read_only = null ): array {
327 $state = self::state();
328 $scopes = null === $read_only
329 ? ( ! empty( $state['scopes'] ) ? $state['scopes'] : self::DEFAULT_SCOPES )
330 : self::scopes_for( $read_only );
331
332 update_option(
333 self::OPTION,
334 [
335 'site_token' => self::mint_token(),
336 'connected' => true,
337 'connected_at' => time(),
338 'scopes' => $scopes,
339 'user_id' => get_current_user_id() ? get_current_user_id() : $state['user_id'],
340 ],
341 false
342 );
343
344 return self::public_status();
345 }
346
347 /**
348 * Disconnect — revoke the connection token AND every OAuth grant, so
349 * Disconnect is a single kill switch for ALL MCP access.
350 *
351 * @return array<string,mixed> Public status after disconnect.
352 */
353 public static function disconnect(): array {
354 delete_option( self::OPTION );
355 Mcp_OAuth::revoke_all();
356
357 return self::public_status();
358 }
359
360 /**
361 * Map a read-only flag to the granted scope list.
362 *
363 * @param bool $read_only Whether to grant read-only access.
364 * @return string[]
365 */
366 private static function scopes_for( bool $read_only ): array {
367 return $read_only ? [ 'read' ] : self::DEFAULT_SCOPES;
368 }
369
370 /**
371 * Mint a 32-byte random token (64 hex chars).
372 *
373 * @return string
374 */
375 private static function mint_token(): string {
376 return bin2hex( random_bytes( 32 ) );
377 }
378 }
379