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

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