PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 1.25.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v1.25.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-server.php

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

361 lines 11.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * MCP server — the per-site JSON-RPC endpoint.
4 *
5 * The plugin speaks the MCP protocol directly at this site's own URL
6 * (https://thissite.com/thinkrank/mcp), so there is NO hosted broker in the
7 * path. MCP's Streamable-HTTP transport is JSON-RPC 2.0 over HTTP POST. We
8 * implement the small server surface an AI client needs:
9 * - initialize → capabilities + serverInfo
10 * - notifications/* → acknowledged (no response body)
11 * - ping → {}
12 * - tools/list → Mcp_Tools::list()
13 * - tools/call → Mcp_Tools::invoke() wrapped as MCP content
14 *
15 * Auth: either the static pairing token (Mcp_Pairing) or an OAuth 2.1 access
16 * token (Mcp_OAuth), both presented as a Bearer token. On success the request
17 * runs AS the admin who granted the credential (wp_set_current_user), so
18 * every ability's own capability check still applies. A single
19 * unauthenticated call gets a JSON-RPC 401 + RFC 9728 WWW-Authenticate
20 * challenge that points OAuth-capable clients at the discovery metadata.
21 *
22 * @package ThinkRank\Mcp
23 */
24
25 declare(strict_types=1);
26
27 namespace ThinkRank\Mcp;
28
29 if ( ! defined( 'ABSPATH' ) ) {
30 exit; // Exit if accessed directly.
31 }
32
33 /**
34 * JSON-RPC 2.0 handler for the ThinkRank MCP endpoint.
35 */
36 final class Mcp_Server {
37
38 /**
39 * MCP protocol version this server implements.
40 */
41 public const PROTOCOL_VERSION = '2025-06-18';
42
43 /**
44 * JSON-RPC standard error codes.
45 */
46 private const PARSE_ERROR = -32700;
47 private const INVALID_REQUEST = -32600;
48 private const METHOD_NOT_FOUND = -32601;
49 private const INVALID_PARAMS = -32602;
50 private const UNAUTHORIZED = -32001;
51
52 /**
53 * Handle a raw MCP HTTP request. Reads the JSON-RPC message from the
54 * request body, dispatches it, and returns a WP_REST_Response (or a
55 * 202 with empty body for notifications).
56 *
57 * @param \WP_REST_Request $request Incoming request (raw body).
58 * @return \WP_REST_Response
59 */
60 public static function handle( \WP_REST_Request $request ): \WP_REST_Response {
61 // Diagnostic tap: define THINKRANK_MCP_DEBUG in wp-config.php to log
62 // every inbound MCP request (pre-auth) to the PHP error log. Bodies
63 // are truncated; credentials are never logged.
64 if ( defined( 'THINKRANK_MCP_DEBUG' ) && THINKRANK_MCP_DEBUG ) {
65 error_log( sprintf( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- opt-in debug tap.
66 '[TR-MCP] in method=%s auth=%s accept=%s body=%s',
67 isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : '?',
68 $request->get_header( 'authorization' ) ? 'yes' : 'no',
69 (string) $request->get_header( 'accept' ),
70 substr( (string) $request->get_body(), 0, 300 )
71 ) );
72 }
73
74 // The admin toggle is the master switch: off = no MCP surface at all.
75 if ( ! Mcp_Manager::is_enabled() ) {
76 return self::error_response( null, self::UNAUTHORIZED, 'MCP is disabled on this site. Enable it under ThinkRank → MCP.', 403 );
77 }
78
79 // Lockout check first: a rate-limited IP never reaches the compare.
80 if ( Mcp_Rate_Limiter::is_locked() ) {
81 return self::error_response( null, self::UNAUTHORIZED, 'Too many failed attempts. Try again later.', 429 );
82 }
83
84 // Authenticate: static pairing token OR an OAuth 2.1 access token
85 // (both Bearer). Either satisfies the gate.
86 if ( true !== self::authorize( $request ) ) {
87 Mcp_Rate_Limiter::record_failure();
88 $response = self::error_response( null, self::UNAUTHORIZED, 'Unauthorized: invalid or missing connection token.', 401 );
89 // RFC 9728 challenge: point OAuth-capable clients at the
90 // protected-resource metadata so they can start the auth flow.
91 $response->header( 'WWW-Authenticate', self::challenge_header() );
92 return $response;
93 }
94 Mcp_Rate_Limiter::clear();
95
96 $raw = $request->get_body();
97 $msg = json_decode( $raw, true );
98
99 if ( null === $msg && JSON_ERROR_NONE !== json_last_error() ) {
100 return self::error_response( null, self::PARSE_ERROR, 'Parse error: body is not valid JSON.', 400 );
101 }
102
103 // Batched requests: an array of messages. Handle each; drop
104 // notification (id-less) responses per JSON-RPC.
105 if ( is_array( $msg ) && array_key_exists( 0, $msg ) ) {
106 $responses = [];
107 foreach ( $msg as $one ) {
108 $r = self::dispatch( is_array( $one ) ? $one : [] );
109 if ( null !== $r ) {
110 $responses[] = $r;
111 }
112 }
113 if ( empty( $responses ) ) {
114 return new \WP_REST_Response( null, 202 );
115 }
116 return new \WP_REST_Response( $responses, 200 );
117 }
118
119 if ( ! is_array( $msg ) ) {
120 return self::error_response( null, self::INVALID_REQUEST, 'Invalid request.', 400 );
121 }
122
123 $response = self::dispatch( $msg );
124 if ( null === $response ) {
125 // Notification — no response body, 202 Accepted.
126 return new \WP_REST_Response( null, 202 );
127 }
128 return new \WP_REST_Response( $response, 200 );
129 }
130
131 /**
132 * Dispatch a single JSON-RPC message. Returns the response array, or
133 * null for notifications (messages with no `id`).
134 *
135 * @param array $msg Decoded JSON-RPC message.
136 * @return array|null
137 */
138 private static function dispatch( array $msg ): ?array {
139 $method = isset( $msg['method'] ) ? (string) $msg['method'] : '';
140 $id = $msg['id'] ?? null;
141 $params = isset( $msg['params'] ) && is_array( $msg['params'] ) ? $msg['params'] : [];
142
143 // Notifications (no id) get acknowledged with no response.
144 $is_notification = ! array_key_exists( 'id', $msg );
145
146 switch ( $method ) {
147 case 'initialize':
148 return self::result(
149 $id,
150 [
151 'protocolVersion' => self::PROTOCOL_VERSION,
152 'capabilities' => [
153 'tools' => [ 'listChanged' => false ],
154 ],
155 'serverInfo' => [
156 'name' => 'thinkrank',
157 'version' => defined( 'THINKRANK_VERSION' ) ? THINKRANK_VERSION : '1.0.0',
158 ],
159 ]
160 );
161
162 case 'ping':
163 return self::result( $id, (object) [] );
164
165 case 'tools/list':
166 return self::result( $id, [ 'tools' => Mcp_Tools::list() ] );
167
168 case 'tools/call':
169 return self::call_tool( $id, $params );
170
171 default:
172 // notifications/initialized, notifications/cancelled, etc.
173 if ( $is_notification || 0 === strpos( $method, 'notifications/' ) ) {
174 return null;
175 }
176 return self::error( $id, self::METHOD_NOT_FOUND, 'Method not found: ' . $method );
177 }
178 }
179
180 /**
181 * Execute a tools/call request and wrap the result in MCP content.
182 *
183 * @param mixed $id JSON-RPC id.
184 * @param array $params { name:string, arguments:array }.
185 * @return array
186 */
187 private static function call_tool( $id, array $params ): array {
188 $name = isset( $params['name'] ) ? (string) $params['name'] : '';
189 $args = isset( $params['arguments'] ) && is_array( $params['arguments'] ) ? $params['arguments'] : [];
190
191 if ( '' === $name ) {
192 return self::error( $id, self::INVALID_PARAMS, 'Missing tool name.' );
193 }
194
195 $result = Mcp_Tools::invoke( $name, $args );
196
197 if ( is_wp_error( $result ) ) {
198 // Tool-level failure is reported as a successful JSON-RPC
199 // response with isError=true (per MCP), so the model can read
200 // the message rather than the transport swallowing it.
201 return self::result(
202 $id,
203 [
204 'content' => [
205 [
206 'type' => 'text',
207 'text' => $result->get_error_message(),
208 ],
209 ],
210 'isError' => true,
211 ]
212 );
213 }
214
215 return self::result(
216 $id,
217 [
218 'content' => [
219 [
220 'type' => 'text',
221 'text' => wp_json_encode( $result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ),
222 ],
223 ],
224 'isError' => false,
225 ]
226 );
227 }
228
229 // -- Auth --
230
231 /**
232 * Validate the Bearer credential — pairing token or OAuth access token.
233 * On success, switch the request to the granting admin's user so every
234 * ability's own permission callback (current_user_can) still applies.
235 *
236 * @param \WP_REST_Request $request Incoming request.
237 * @return bool
238 */
239 private static function authorize( \WP_REST_Request $request ): bool {
240 $presented = self::extract_token( $request );
241 if ( '' === $presented ) {
242 return false;
243 }
244
245 // Path 1: the static per-site pairing token. Leave the tool scope
246 // override cleared so Mcp_Tools defers to the pairing token's scope.
247 $stored = Mcp_Pairing::site_token();
248 if ( '' !== $stored && hash_equals( $stored, $presented ) ) {
249 Mcp_Tools::set_read_only_override( null );
250 return self::impersonate( Mcp_Pairing::user_id() );
251 }
252
253 // Path 2: an OAuth 2.1 access token minted by Mcp_OAuth. Its own
254 // granted scope decides read-only, independent of the pairing token.
255 $grant = Mcp_OAuth::validate_token( $presented );
256 if ( null !== $grant ) {
257 Mcp_Tools::set_read_only_override( Mcp_OAuth::scope_is_read_only( $grant['scope'] ) );
258 return self::impersonate( $grant['user_id'] );
259 }
260
261 return false;
262 }
263
264 /**
265 * Run the request as the admin who granted the credential. Refuses when
266 * the stored user no longer exists or lost manage_options — a demoted or
267 * deleted admin's grants die with them.
268 *
269 * @param int $user_id Granting user id.
270 * @return bool
271 */
272 private static function impersonate( int $user_id ): bool {
273 if ( $user_id <= 0 ) {
274 return false;
275 }
276 $user = get_user_by( 'id', $user_id );
277 if ( ! $user || ! user_can( $user, 'manage_options' ) ) {
278 return false;
279 }
280 wp_set_current_user( $user_id );
281 return true;
282 }
283
284 /**
285 * The RFC 9728 WWW-Authenticate challenge value. Points the client at
286 * this site's protected-resource metadata so an OAuth-capable client
287 * can discover the authorization server and begin the flow.
288 *
289 * @return string
290 */
291 private static function challenge_header(): string {
292 // The path-suffixed form (RFC 9728 §3.1) — specific to OUR resource,
293 // so it can't collide with another plugin's root-form metadata.
294 $metadata_url = home_url( '/.well-known/oauth-protected-resource/' . Mcp_Pairing::SITE_ENDPOINT_PATH );
295 return sprintf( 'Bearer resource_metadata="%s"', $metadata_url );
296 }
297
298 /**
299 * Pull the token from the Authorization: Bearer header.
300 *
301 * @param \WP_REST_Request $request Incoming request.
302 * @return string
303 */
304 private static function extract_token( \WP_REST_Request $request ): string {
305 $auth = $request->get_header( 'authorization' );
306 if ( is_string( $auth ) && preg_match( '/^Bearer\s+(.+)$/i', trim( $auth ), $m ) ) {
307 return trim( $m[1] );
308 }
309 return '';
310 }
311
312 // -- JSON-RPC envelope helpers --
313
314 /**
315 * Build a JSON-RPC success envelope.
316 *
317 * @param mixed $id JSON-RPC id.
318 * @param mixed $result Result payload.
319 * @return array
320 */
321 private static function result( $id, $result ): array {
322 return [
323 'jsonrpc' => '2.0',
324 'id' => $id,
325 'result' => $result,
326 ];
327 }
328
329 /**
330 * Build a JSON-RPC error envelope (for a single message).
331 *
332 * @param mixed $id JSON-RPC id.
333 * @param int $code JSON-RPC error code.
334 * @param string $message Error message.
335 * @return array
336 */
337 private static function error( $id, int $code, string $message ): array {
338 return [
339 'jsonrpc' => '2.0',
340 'id' => $id,
341 'error' => [
342 'code' => $code,
343 'message' => $message,
344 ],
345 ];
346 }
347
348 /**
349 * Build a top-level error WP_REST_Response with an HTTP status.
350 *
351 * @param mixed $id JSON-RPC id.
352 * @param int $code JSON-RPC error code.
353 * @param string $message Error message.
354 * @param int $http HTTP status.
355 * @return \WP_REST_Response
356 */
357 private static function error_response( $id, int $code, string $message, int $http ): \WP_REST_Response {
358 return new \WP_REST_Response( self::error( $id, $code, $message ), $http );
359 }
360 }
361