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

424 lines 14.6 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 //
121 // KEPT DELIBERATELY, not left behind by accident. The revision we
122 // advertise in PROTOCOL_VERSION (2025-06-18) removed JSON-RPC
123 // batching, so this is more than the spec requires — but accepting a
124 // batch harms nobody, while refusing one would break any client still
125 // on an older SDK that sends them. Please don't delete this as a spec
126 // violation; that trade is the reason it is here (#488).
127 if ( is_array( $msg ) && array_key_exists( 0, $msg ) ) {
128 $responses = [];
129 foreach ( $msg as $one ) {
130 $r = self::dispatch( is_array( $one ) ? $one : [] );
131 if ( null !== $r ) {
132 $responses[] = $r;
133 }
134 }
135 if ( empty( $responses ) ) {
136 return new \WP_REST_Response( null, 202 );
137 }
138 return new \WP_REST_Response( $responses, 200 );
139 }
140
141 if ( ! is_array( $msg ) ) {
142 return self::error_response( null, self::INVALID_REQUEST, 'Invalid request.', 400 );
143 }
144
145 $response = self::dispatch( $msg );
146 if ( null === $response ) {
147 // Notification — no response body, 202 Accepted.
148 return new \WP_REST_Response( null, 202 );
149 }
150 return new \WP_REST_Response( $response, 200 );
151 }
152
153 /**
154 * Dispatch a single JSON-RPC message. Returns the response array, or
155 * null for notifications (messages with no `id`).
156 *
157 * @param array $msg Decoded JSON-RPC message.
158 * @return array|null
159 */
160 private static function dispatch( array $msg ): ?array {
161 $method = isset( $msg['method'] ) ? (string) $msg['method'] : '';
162 $id = $msg['id'] ?? null;
163 $params = isset( $msg['params'] ) && is_array( $msg['params'] ) ? $msg['params'] : [];
164
165 // Notifications (no id) get acknowledged with no response.
166 $is_notification = ! array_key_exists( 'id', $msg );
167
168 $response = self::handle_method( $method, $id, $params, $is_notification );
169
170 // JSON-RPC 2.0: a message with no `id` is a notification and MUST NOT
171 // be answered. Only the default branch below used to consult this, so
172 // initialize, ping, tools/list and tools/call sent without an id all
173 // fell through to self::result( null, ... ) and were answered with a
174 // 200 carrying "id": null instead of the 202 with no body a
175 // notification should get (#488). The message is still PROCESSED —
176 // only the reply is suppressed, which is what the spec asks for.
177 return $is_notification ? null : $response;
178 }
179
180 /**
181 * Run one JSON-RPC method. Whether the caller wanted an answer is
182 * dispatch()'s business, not this method's.
183 *
184 * @param string $method Method name.
185 * @param mixed $id JSON-RPC id (null for a notification).
186 * @param array $params Method params.
187 * @param bool $is_notification Whether the message carried no id.
188 * @return array|null
189 */
190 private static function handle_method( string $method, $id, array $params, bool $is_notification ): ?array {
191 switch ( $method ) {
192 case 'initialize':
193 return self::result(
194 $id,
195 [
196 'protocolVersion' => self::PROTOCOL_VERSION,
197 'capabilities' => [
198 'tools' => [ 'listChanged' => false ],
199 ],
200 'serverInfo' => [
201 'name' => 'thinkrank',
202 'version' => defined( 'THINKRANK_VERSION' ) ? THINKRANK_VERSION : '1.0.0',
203 ],
204 ]
205 );
206
207 case 'ping':
208 return self::result( $id, (object) [] );
209
210 case 'tools/list':
211 $tools = Mcp_Tools::list();
212 // An empty list while MCP is enabled means the Abilities
213 // runtime never loaded (broken package) — the client sees a
214 // clean, useless connection. Leave a trail for whoever debugs
215 // it; the admin notice and self-test carry the loud version.
216 if ( empty( $tools ) && defined( 'WP_DEBUG' ) && WP_DEBUG ) {
217 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.
218 }
219 return self::result( $id, [ 'tools' => $tools ] );
220
221 case 'tools/call':
222 return self::call_tool( $id, $params );
223
224 default:
225 // notifications/initialized, notifications/cancelled, etc.
226 if ( $is_notification || 0 === strpos( $method, 'notifications/' ) ) {
227 return null;
228 }
229 return self::error( $id, self::METHOD_NOT_FOUND, 'Method not found: ' . $method );
230 }
231 }
232
233 /**
234 * Execute a tools/call request and wrap the result in MCP content.
235 *
236 * @param mixed $id JSON-RPC id.
237 * @param array $params { name:string, arguments:array }.
238 * @return array
239 */
240 private static function call_tool( $id, array $params ): array {
241 $name = isset( $params['name'] ) ? (string) $params['name'] : '';
242 $args = isset( $params['arguments'] ) && is_array( $params['arguments'] ) ? $params['arguments'] : [];
243
244 if ( '' === $name ) {
245 return self::error( $id, self::INVALID_PARAMS, 'Missing tool name.' );
246 }
247
248 $result = Mcp_Tools::invoke( $name, $args );
249
250 if ( is_wp_error( $result ) ) {
251 // Tool-level failure is reported as a successful JSON-RPC
252 // response with isError=true (per MCP), so the model can read
253 // the message rather than the transport swallowing it.
254 return self::result(
255 $id,
256 [
257 'content' => [
258 [
259 'type' => 'text',
260 'text' => $result->get_error_message(),
261 ],
262 ],
263 'isError' => true,
264 ]
265 );
266 }
267
268 return self::result(
269 $id,
270 [
271 'content' => [
272 [
273 'type' => 'text',
274 'text' => wp_json_encode( $result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ),
275 ],
276 ],
277 'isError' => false,
278 ]
279 );
280 }
281
282 // -- Auth --
283
284 /**
285 * Validate the Bearer credential — pairing token or OAuth access token.
286 * On success, switch the request to the granting admin's user so every
287 * ability's own permission callback (current_user_can) still applies.
288 *
289 * @param \WP_REST_Request $request Incoming request.
290 * @return bool
291 */
292 private static function authorize( \WP_REST_Request $request ): bool {
293 $presented = self::extract_token( $request );
294 if ( '' === $presented ) {
295 return false;
296 }
297
298 // Path 1: the static per-site pairing token. Leave the tool scope
299 // override cleared so Mcp_Tools defers to the pairing token's scope.
300 //
301 // Compared through Mcp_Pairing::verify_token(), which checks the stored
302 // hash rather than a plaintext copy — the token is encrypted at rest and
303 // only its hash is used to authenticate (#396).
304 if ( Mcp_Pairing::verify_token( $presented ) ) {
305 Mcp_Tools::set_read_only_override( null );
306 if ( self::impersonate( Mcp_Pairing::user_id() ) ) {
307 // Record activity for the "Static token connections" row.
308 Mcp_Pairing::touch_last_used();
309 return true;
310 }
311 return false;
312 }
313
314 // Path 2: an OAuth 2.1 access token minted by Mcp_OAuth. Its own
315 // granted scope decides read-only, independent of the pairing token.
316 $grant = Mcp_OAuth::validate_token( $presented );
317 if ( null !== $grant ) {
318 Mcp_Tools::set_read_only_override( Mcp_OAuth::scope_is_read_only( $grant['scope'] ) );
319 return self::impersonate( $grant['user_id'] );
320 }
321
322 return false;
323 }
324
325 /**
326 * Run the request as the admin who granted the credential. Refuses when
327 * the stored user no longer exists or lost manage_options — a demoted or
328 * deleted admin's grants die with them.
329 *
330 * @param int $user_id Granting user id.
331 * @return bool
332 */
333 private static function impersonate( int $user_id ): bool {
334 if ( $user_id <= 0 ) {
335 return false;
336 }
337 $user = get_user_by( 'id', $user_id );
338 if ( ! $user || ! user_can( $user, 'manage_options' ) ) {
339 return false;
340 }
341 wp_set_current_user( $user_id );
342 return true;
343 }
344
345 /**
346 * The RFC 9728 WWW-Authenticate challenge value. Points the client at
347 * this site's protected-resource metadata so an OAuth-capable client
348 * can discover the authorization server and begin the flow.
349 *
350 * @return string
351 */
352 private static function challenge_header(): string {
353 // REST-served, not the /.well-known/ path-insert form: some hosts
354 // (SiteGround) intercept root /.well-known/ at their Nginx edge and
355 // 404 it before WordPress runs, killing the flow on the client's very
356 // first fetch. See Mcp_OAuth::resource_metadata_url() for the full
357 // reasoning and the override filter.
358 return sprintf( 'Bearer resource_metadata="%s"', Mcp_OAuth::resource_metadata_url() );
359 }
360
361 /**
362 * Pull the token from the Authorization: Bearer header.
363 *
364 * @param \WP_REST_Request $request Incoming request.
365 * @return string
366 */
367 private static function extract_token( \WP_REST_Request $request ): string {
368 $auth = $request->get_header( 'authorization' );
369 if ( is_string( $auth ) && preg_match( '/^Bearer\s+(.+)$/i', trim( $auth ), $m ) ) {
370 return trim( $m[1] );
371 }
372 return '';
373 }
374
375 // -- JSON-RPC envelope helpers --
376
377 /**
378 * Build a JSON-RPC success envelope.
379 *
380 * @param mixed $id JSON-RPC id.
381 * @param mixed $result Result payload.
382 * @return array
383 */
384 private static function result( $id, $result ): array {
385 return [
386 'jsonrpc' => '2.0',
387 'id' => $id,
388 'result' => $result,
389 ];
390 }
391
392 /**
393 * Build a JSON-RPC error envelope (for a single message).
394 *
395 * @param mixed $id JSON-RPC id.
396 * @param int $code JSON-RPC error code.
397 * @param string $message Error message.
398 * @return array
399 */
400 private static function error( $id, int $code, string $message ): array {
401 return [
402 'jsonrpc' => '2.0',
403 'id' => $id,
404 'error' => [
405 'code' => $code,
406 'message' => $message,
407 ],
408 ];
409 }
410
411 /**
412 * Build a top-level error WP_REST_Response with an HTTP status.
413 *
414 * @param mixed $id JSON-RPC id.
415 * @param int $code JSON-RPC error code.
416 * @param string $message Error message.
417 * @param int $http HTTP status.
418 * @return \WP_REST_Response
419 */
420 private static function error_response( $id, int $code, string $message, int $http ): \WP_REST_Response {
421 return new \WP_REST_Response( self::error( $id, $code, $message ), $http );
422 }
423 }
424