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-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.32.0, at includes/mcp/class-mcp-server.php

391 lines 12.9 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 // A request carrying NO credential is the normal opening move of the
80 // OAuth flow — the client is asking for the RFC 9728 challenge, not
81 // guessing a token. Only a credential that was PRESENTED and rejected
82 // counts against the limiter, and only such a request can be locked
83 // out; otherwise every OAuth-capable client walls itself off after
84 // DEFAULT_MAX_FAILS discovery probes.
85 $presented = self::extract_token( $request );
86
87 // Lockout check first: a rate-limited IP never reaches the compare.
88 if ( '' !== $presented && Mcp_Rate_Limiter::is_locked() ) {
89 $response = self::error_response( null, self::UNAUTHORIZED, 'Too many failed attempts. Try again later.', 429 );
90 // Keep the challenge on the 429 too: a client that only ever sees
91 // a bare 429 concludes the server has no OAuth at all.
92 $response->header( 'WWW-Authenticate', self::challenge_header() );
93 $response->header( 'Retry-After', (string) Mcp_Rate_Limiter::retry_after() );
94 return $response;
95 }
96
97 // Authenticate: static pairing token OR an OAuth 2.1 access token
98 // (both Bearer). Either satisfies the gate.
99 if ( true !== self::authorize( $request ) ) {
100 if ( '' !== $presented ) {
101 Mcp_Rate_Limiter::record_failure();
102 }
103 $response = self::error_response( null, self::UNAUTHORIZED, 'Unauthorized: invalid or missing connection token.', 401 );
104 // RFC 9728 challenge: point OAuth-capable clients at the
105 // protected-resource metadata so they can start the auth flow.
106 $response->header( 'WWW-Authenticate', self::challenge_header() );
107 return $response;
108 }
109 Mcp_Rate_Limiter::clear();
110
111 $raw = $request->get_body();
112 $msg = json_decode( $raw, true );
113
114 if ( null === $msg && JSON_ERROR_NONE !== json_last_error() ) {
115 return self::error_response( null, self::PARSE_ERROR, 'Parse error: body is not valid JSON.', 400 );
116 }
117
118 // Batched requests: an array of messages. Handle each; drop
119 // notification (id-less) responses per JSON-RPC.
120 if ( is_array( $msg ) && array_key_exists( 0, $msg ) ) {
121 $responses = [];
122 foreach ( $msg as $one ) {
123 $r = self::dispatch( is_array( $one ) ? $one : [] );
124 if ( null !== $r ) {
125 $responses[] = $r;
126 }
127 }
128 if ( empty( $responses ) ) {
129 return new \WP_REST_Response( null, 202 );
130 }
131 return new \WP_REST_Response( $responses, 200 );
132 }
133
134 if ( ! is_array( $msg ) ) {
135 return self::error_response( null, self::INVALID_REQUEST, 'Invalid request.', 400 );
136 }
137
138 $response = self::dispatch( $msg );
139 if ( null === $response ) {
140 // Notification — no response body, 202 Accepted.
141 return new \WP_REST_Response( null, 202 );
142 }
143 return new \WP_REST_Response( $response, 200 );
144 }
145
146 /**
147 * Dispatch a single JSON-RPC message. Returns the response array, or
148 * null for notifications (messages with no `id`).
149 *
150 * @param array $msg Decoded JSON-RPC message.
151 * @return array|null
152 */
153 private static function dispatch( array $msg ): ?array {
154 $method = isset( $msg['method'] ) ? (string) $msg['method'] : '';
155 $id = $msg['id'] ?? null;
156 $params = isset( $msg['params'] ) && is_array( $msg['params'] ) ? $msg['params'] : [];
157
158 // Notifications (no id) get acknowledged with no response.
159 $is_notification = ! array_key_exists( 'id', $msg );
160
161 switch ( $method ) {
162 case 'initialize':
163 return self::result(
164 $id,
165 [
166 'protocolVersion' => self::PROTOCOL_VERSION,
167 'capabilities' => [
168 'tools' => [ 'listChanged' => false ],
169 ],
170 'serverInfo' => [
171 'name' => 'thinkrank',
172 'version' => defined( 'THINKRANK_VERSION' ) ? THINKRANK_VERSION : '1.0.0',
173 ],
174 ]
175 );
176
177 case 'ping':
178 return self::result( $id, (object) [] );
179
180 case 'tools/list':
181 $tools = Mcp_Tools::list();
182 // An empty list while MCP is enabled means the Abilities
183 // runtime never loaded (broken package) — the client sees a
184 // clean, useless connection. Leave a trail for whoever debugs
185 // it; the admin notice and self-test carry the loud version.
186 if ( empty( $tools ) && defined( 'WP_DEBUG' ) && WP_DEBUG ) {
187 error_log( '[TR-MCP] tools/list returned 0 tools. ' . \ThinkRank\Abilities\Abilities_Registrar::summary() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- WP_DEBUG-gated diagnostic.
188 }
189 return self::result( $id, [ 'tools' => $tools ] );
190
191 case 'tools/call':
192 return self::call_tool( $id, $params );
193
194 default:
195 // notifications/initialized, notifications/cancelled, etc.
196 if ( $is_notification || 0 === strpos( $method, 'notifications/' ) ) {
197 return null;
198 }
199 return self::error( $id, self::METHOD_NOT_FOUND, 'Method not found: ' . $method );
200 }
201 }
202
203 /**
204 * Execute a tools/call request and wrap the result in MCP content.
205 *
206 * @param mixed $id JSON-RPC id.
207 * @param array $params { name:string, arguments:array }.
208 * @return array
209 */
210 private static function call_tool( $id, array $params ): array {
211 $name = isset( $params['name'] ) ? (string) $params['name'] : '';
212 $args = isset( $params['arguments'] ) && is_array( $params['arguments'] ) ? $params['arguments'] : [];
213
214 if ( '' === $name ) {
215 return self::error( $id, self::INVALID_PARAMS, 'Missing tool name.' );
216 }
217
218 $result = Mcp_Tools::invoke( $name, $args );
219
220 if ( is_wp_error( $result ) ) {
221 // Tool-level failure is reported as a successful JSON-RPC
222 // response with isError=true (per MCP), so the model can read
223 // the message rather than the transport swallowing it.
224 return self::result(
225 $id,
226 [
227 'content' => [
228 [
229 'type' => 'text',
230 'text' => $result->get_error_message(),
231 ],
232 ],
233 'isError' => true,
234 ]
235 );
236 }
237
238 return self::result(
239 $id,
240 [
241 'content' => [
242 [
243 'type' => 'text',
244 'text' => wp_json_encode( $result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ),
245 ],
246 ],
247 'isError' => false,
248 ]
249 );
250 }
251
252 // -- Auth --
253
254 /**
255 * Validate the Bearer credential — pairing token or OAuth access token.
256 * On success, switch the request to the granting admin's user so every
257 * ability's own permission callback (current_user_can) still applies.
258 *
259 * @param \WP_REST_Request $request Incoming request.
260 * @return bool
261 */
262 private static function authorize( \WP_REST_Request $request ): bool {
263 $presented = self::extract_token( $request );
264 if ( '' === $presented ) {
265 return false;
266 }
267
268 // Path 1: the static per-site pairing token. Leave the tool scope
269 // override cleared so Mcp_Tools defers to the pairing token's scope.
270 $stored = Mcp_Pairing::site_token();
271 if ( '' !== $stored && hash_equals( $stored, $presented ) ) {
272 Mcp_Tools::set_read_only_override( null );
273 if ( self::impersonate( Mcp_Pairing::user_id() ) ) {
274 // Record activity for the "Static token connections" row.
275 Mcp_Pairing::touch_last_used();
276 return true;
277 }
278 return false;
279 }
280
281 // Path 2: an OAuth 2.1 access token minted by Mcp_OAuth. Its own
282 // granted scope decides read-only, independent of the pairing token.
283 $grant = Mcp_OAuth::validate_token( $presented );
284 if ( null !== $grant ) {
285 Mcp_Tools::set_read_only_override( Mcp_OAuth::scope_is_read_only( $grant['scope'] ) );
286 return self::impersonate( $grant['user_id'] );
287 }
288
289 return false;
290 }
291
292 /**
293 * Run the request as the admin who granted the credential. Refuses when
294 * the stored user no longer exists or lost manage_options — a demoted or
295 * deleted admin's grants die with them.
296 *
297 * @param int $user_id Granting user id.
298 * @return bool
299 */
300 private static function impersonate( int $user_id ): bool {
301 if ( $user_id <= 0 ) {
302 return false;
303 }
304 $user = get_user_by( 'id', $user_id );
305 if ( ! $user || ! user_can( $user, 'manage_options' ) ) {
306 return false;
307 }
308 wp_set_current_user( $user_id );
309 return true;
310 }
311
312 /**
313 * The RFC 9728 WWW-Authenticate challenge value. Points the client at
314 * this site's protected-resource metadata so an OAuth-capable client
315 * can discover the authorization server and begin the flow.
316 *
317 * @return string
318 */
319 private static function challenge_header(): string {
320 // REST-served, not the /.well-known/ path-insert form: some hosts
321 // (SiteGround) intercept root /.well-known/ at their Nginx edge and
322 // 404 it before WordPress runs, killing the flow on the client's very
323 // first fetch. See Mcp_OAuth::resource_metadata_url() for the full
324 // reasoning and the override filter.
325 return sprintf( 'Bearer resource_metadata="%s"', Mcp_OAuth::resource_metadata_url() );
326 }
327
328 /**
329 * Pull the token from the Authorization: Bearer header.
330 *
331 * @param \WP_REST_Request $request Incoming request.
332 * @return string
333 */
334 private static function extract_token( \WP_REST_Request $request ): string {
335 $auth = $request->get_header( 'authorization' );
336 if ( is_string( $auth ) && preg_match( '/^Bearer\s+(.+)$/i', trim( $auth ), $m ) ) {
337 return trim( $m[1] );
338 }
339 return '';
340 }
341
342 // -- JSON-RPC envelope helpers --
343
344 /**
345 * Build a JSON-RPC success envelope.
346 *
347 * @param mixed $id JSON-RPC id.
348 * @param mixed $result Result payload.
349 * @return array
350 */
351 private static function result( $id, $result ): array {
352 return [
353 'jsonrpc' => '2.0',
354 'id' => $id,
355 'result' => $result,
356 ];
357 }
358
359 /**
360 * Build a JSON-RPC error envelope (for a single message).
361 *
362 * @param mixed $id JSON-RPC id.
363 * @param int $code JSON-RPC error code.
364 * @param string $message Error message.
365 * @return array
366 */
367 private static function error( $id, int $code, string $message ): array {
368 return [
369 'jsonrpc' => '2.0',
370 'id' => $id,
371 'error' => [
372 'code' => $code,
373 'message' => $message,
374 ],
375 ];
376 }
377
378 /**
379 * Build a top-level error WP_REST_Response with an HTTP status.
380 *
381 * @param mixed $id JSON-RPC id.
382 * @param int $code JSON-RPC error code.
383 * @param string $message Error message.
384 * @param int $http HTTP status.
385 * @return \WP_REST_Response
386 */
387 private static function error_response( $id, int $code, string $message, int $http ): \WP_REST_Response {
388 return new \WP_REST_Response( self::error( $id, $code, $message ), $http );
389 }
390 }
391