PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.7
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.7
1.3.7 1.3.6 1.3.5 1.3.4 1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 All 33 releases
xspeed / includes / modules / Mcp / Mcp_Server.php

Mcp_Server.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.3.7, at includes/modules/Mcp/Mcp_Server.php

366 lines 12.8 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 * This is the primary way an AI assistant talks to xSpeed: the plugin
6 * speaks the MCP protocol directly at this site's own URL
7 * (https://thissite.com/xspeed/mcp), so there is NO hosted broker in the
8 * path. The user pastes their own site's MCP URL + connection token into
9 * their AI client.
10 *
11 * MCP's Streamable-HTTP transport is JSON-RPC 2.0 over HTTP POST. We
12 * implement the small server surface an AI client needs:
13 * - initialize → capabilities + serverInfo
14 * - notifications/* → acknowledged (no response body)
15 * - ping → {}
16 * - tools/list → Mcp_Tools::list()
17 * - tools/call → Mcp_Tools::invoke() wrapped as MCP content
18 *
19 * Auth: the connection token is presented either as a Bearer token
20 * (Authorization header) or the X-XSpeed-MCP-Token header; both are
21 * validated against the stored site_token by Mcp_Auth. A single
22 * unauthenticated call gets a JSON-RPC error, never the tool result.
23 *
24 * @package XSpeed
25 */
26
27 declare(strict_types=1);
28
29 namespace XSpeed\Modules\Mcp;
30
31 defined( 'ABSPATH' ) || exit;
32
33 final class Mcp_Server {
34
35 /** MCP protocol version this server implements. */
36 public const PROTOCOL_VERSION = '2025-06-18';
37
38 /** JSON-RPC standard error codes. */
39 private const PARSE_ERROR = -32700;
40 private const INVALID_REQUEST = -32600;
41 private const METHOD_NOT_FOUND = -32601;
42 private const INVALID_PARAMS = -32602;
43 private const UNAUTHORIZED = -32001;
44
45 /**
46 * Handle a raw MCP HTTP request. Reads the JSON-RPC message from the
47 * request body, dispatches it, and returns a WP_REST_Response (or a
48 * 202 with empty body for notifications).
49 *
50 * @param \WP_REST_Request $request Incoming request (raw body).
51 */
52 public static function handle( \WP_REST_Request $request ) {
53 // --- Lockout check first: a rate-limited IP never reaches the compare.
54 if ( Mcp_Rate_Limiter::is_locked() ) {
55 return self::error_response( null, self::UNAUTHORIZED, 'Too many failed attempts. Try again later.', 429 );
56 }
57
58 // --- Authenticate: static pairing token (Bearer / X-XSpeed-MCP-Token)
59 // OR an OAuth 2.1 access token (Bearer). Either satisfies the gate.
60 if ( true !== self::authorize( $request ) ) {
61 Mcp_Rate_Limiter::record_failure();
62 $response = self::error_response( null, self::UNAUTHORIZED, 'Unauthorized: invalid or missing connection token.', 401 );
63 // RFC 9728 challenge: point OAuth-capable clients at the
64 // protected-resource metadata so they can start the auth flow.
65 $response->header( 'WWW-Authenticate', self::challenge_header() );
66 return $response;
67 }
68 Mcp_Rate_Limiter::clear();
69
70 $raw = $request->get_body();
71 // Second line behind McpModule::cap_request_body(), which is the one
72 // that runs before WordPress decodes. Kept because the pretty
73 // front-door path reaches here without WP_REST_Server::dispatch().
74 if ( strlen( $raw ) > Mcp_Tools::MAX_TOOL_BODY_BYTES ) {
75 return self::error_response( null, self::INVALID_REQUEST, 'Request body is too large.', 413 );
76 }
77 $msg = json_decode( $raw, true );
78
79 if ( null === $msg && JSON_ERROR_NONE !== json_last_error() ) {
80 return self::error_response( null, self::PARSE_ERROR, 'Parse error: body is not valid JSON.', 400 );
81 }
82
83 // Batched requests: an array of messages. Handle each; drop
84 // notification (id-less) responses per JSON-RPC.
85 if ( is_array( $msg ) && array_key_exists( 0, $msg ) ) {
86 $responses = array();
87 foreach ( $msg as $one ) {
88 $r = self::dispatch( is_array( $one ) ? $one : array() );
89 if ( null !== $r ) {
90 $responses[] = $r;
91 }
92 }
93 // All notifications → 202 Accepted, empty body.
94 if ( empty( $responses ) ) {
95 return new \WP_REST_Response( null, 202 );
96 }
97 return new \WP_REST_Response( $responses, 200 );
98 }
99
100 if ( ! is_array( $msg ) ) {
101 return self::error_response( null, self::INVALID_REQUEST, 'Invalid request.', 400 );
102 }
103
104 $response = self::dispatch( $msg );
105 if ( null === $response ) {
106 // Notification — no response body, 202 Accepted.
107 return new \WP_REST_Response( null, 202 );
108 }
109 return new \WP_REST_Response( $response, 200 );
110 }
111
112 /**
113 * Dispatch a single JSON-RPC message. Returns the response array, or
114 * null for notifications (messages with no `id`).
115 *
116 * @param array $msg Decoded JSON-RPC message.
117 * @return array|null
118 */
119 private static function dispatch( array $msg ) {
120 $method = isset( $msg['method'] ) ? (string) $msg['method'] : '';
121 $id = $msg['id'] ?? null;
122 $params = isset( $msg['params'] ) && is_array( $msg['params'] ) ? $msg['params'] : array();
123
124 // Notifications (no id) get acknowledged with no response.
125 $is_notification = ! array_key_exists( 'id', $msg );
126
127 switch ( $method ) {
128 case 'initialize':
129 return self::result(
130 $id,
131 array(
132 'protocolVersion' => self::PROTOCOL_VERSION,
133 'capabilities' => array(
134 'tools' => array( 'listChanged' => false ),
135 ),
136 'serverInfo' => array(
137 'name' => 'xspeed',
138 'version' => defined( 'XSPEED_VERSION' ) ? XSPEED_VERSION : '1.0.0',
139 'xspeedAuth' => self::$auth_kind,
140 ),
141 )
142 );
143
144 case 'ping':
145 return self::result( $id, (object) array() );
146
147 case 'tools/list':
148 return self::result( $id, array( 'tools' => Mcp_Tools::list() ) );
149
150 case 'tools/call':
151 return self::call_tool( $id, $params );
152
153 default:
154 // notifications/initialized, notifications/cancelled, etc.
155 if ( $is_notification || 0 === strpos( $method, 'notifications/' ) ) {
156 return null;
157 }
158 return self::error( $id, self::METHOD_NOT_FOUND, 'Method not found: ' . $method );
159 }
160 }
161
162 /**
163 * Execute a tools/call request and wrap the result in MCP content.
164 *
165 * @param mixed $id JSON-RPC id.
166 * @param array $params { name:string, arguments:array }.
167 * @return array
168 */
169 private static function call_tool( $id, array $params ) {
170 $name = isset( $params['name'] ) ? (string) $params['name'] : '';
171 $args = isset( $params['arguments'] ) && is_array( $params['arguments'] ) ? $params['arguments'] : array();
172
173 if ( '' === $name ) {
174 return self::error( $id, self::INVALID_PARAMS, 'Missing tool name.' );
175 }
176
177 Mcp_Tools::set_channel( 'mcp' );
178 $result = Mcp_Tools::invoke( $name, $args );
179
180 if ( is_wp_error( $result ) ) {
181 // Tool-level failure is reported as a successful JSON-RPC
182 // response with isError=true (per MCP), so the model can read
183 // the message rather than the transport swallowing it.
184 return self::result(
185 $id,
186 array(
187 'content' => array(
188 array(
189 'type' => 'text',
190 'text' => $result->get_error_message(),
191 ),
192 ),
193 'isError' => true,
194 )
195 );
196 }
197
198 // A Cli_Bridge-backed tool reports command failure as ok:false inside
199 // the payload. Without this, the envelope said isError:false and an
200 // agent read "Could not connect to Redis" as a success.
201 $failed = is_array( $result ) && array_key_exists( 'ok', $result ) && false === $result['ok'];
202
203 return self::result(
204 $id,
205 array(
206 'content' => array(
207 array(
208 'type' => 'text',
209 'text' => wp_json_encode( $result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ),
210 ),
211 ),
212 'isError' => $failed,
213 )
214 );
215 }
216
217 // -- Auth --
218
219 /**
220 * Validate the connection token from either the Authorization: Bearer
221 * header or X-XSpeed-MCP-Token. Reuses Mcp_Auth's constant-time check
222 * against the stored site_token.
223 *
224 * @param \WP_REST_Request $request Incoming request.
225 * @return bool
226 */
227 /**
228 * Which credential authorized this request: 'site' (the pairing token)
229 * or 'oauth'. Reported in `initialize` so xSpeed Hub stores only the
230 * pairing token when a site syncs it. An OAuth token can be read-only
231 * and expires within the hour; storing one would break the Hub.
232 *
233 * @var string
234 */
235 private static $auth_kind = '';
236
237 private static function authorize( \WP_REST_Request $request ): bool {
238 self::$auth_kind = '';
239 $presented = self::extract_token( $request );
240 if ( '' === $presented ) {
241 return false;
242 }
243
244 // Path 1: the static per-site pairing token (Mcp_Pairing). Leave the
245 // tool scope override cleared so Mcp_Tools defers to the pairing
246 // token's own read-only scope. Credential writes over the pairing token
247 // stay gated on the xspeed_mcp_allow_credential_writes filter (off by
248 // default) — clear the configure override so that default applies. (#116)
249 $stored = Mcp_Pairing::site_token();
250 if ( '' !== $stored && hash_equals( $stored, $presented ) ) {
251 Mcp_Tools::set_read_only_override( null );
252 Mcp_Tools::set_configure_override( null );
253 self::$auth_kind = 'site';
254 return true;
255 }
256
257 // Path 2: an OAuth 2.1 access token minted by Mcp_OAuth. Its own
258 // granted scope decides read-only AND whether it may write credentials
259 // (the explicit, opt-in `configure` scope), independent of any pairing
260 // token.
261 $grant = Mcp_OAuth::validate_token( $presented );
262 if ( null !== $grant ) {
263 Mcp_Tools::set_read_only_override( Mcp_OAuth::scope_is_read_only( $grant['scope'] ) );
264 Mcp_Tools::set_configure_override( Mcp_OAuth::scope_allows_configure( $grant['scope'] ) );
265 self::$auth_kind = 'oauth';
266 return true;
267 }
268
269 return false;
270 }
271
272 /**
273 * The RFC 9728 WWW-Authenticate challenge value. Points the client at
274 * this site's protected-resource metadata so an OAuth-capable client
275 * can discover the authorization server and begin the flow.
276 */
277 private static function challenge_header(): string {
278 return sprintf( 'Bearer resource_metadata="%s"', self::metadata_url() );
279 }
280
281 /**
282 * Where this site actually serves its protected-resource metadata.
283 *
284 * Prefers the canonical /.well-known/…/xspeed/mcp URL, but many hosts own that prefix
285 * for ACME/Let's Encrypt and answer it before WordPress runs — the client
286 * then follows a pointer to a 404 (or a redirect to the homepage) and the
287 * OAuth flow dead-ends. RFC 9728 allows a single resource_metadata value,
288 * so when the pretty path is not ours to serve we advertise the /wp-json
289 * fallback, which no ACME tooling claims.
290 */
291 private static function metadata_url(): string {
292 // RFC 9728 §3.1: a resource whose identifier carries a path is
293 // discovered at the path-suffixed form. Always this one, never the
294 // root form — even on a site where root is still ours to serve. The
295 // challenge is what steers every re-discovery, so pointing it at the
296 // canonical identity is what eventually moves clients onto it; and
297 // its value must not depend on whether some other plugin happens to
298 // be installed, or a client that cached the header would find the
299 // URL under it change meaning. Root exists for clients that never
300 // read this header at all. (#266)
301 //
302 // Built off untrailingslashit() because get_home_url() concatenates
303 // the `home` option verbatim: with a trailing slash stored there,
304 // home_url( '/.well-known/…' ) returns a doubled slash and the URL
305 // 404s.
306 $pretty = untrailingslashit( home_url( '/' ) )
307 . '/.well-known/oauth-protected-resource/' . Mcp_Pairing::SITE_ENDPOINT_PATH;
308
309 /**
310 * Filter the advertised protected-resource metadata URL.
311 *
312 * @param string $pretty The canonical /.well-known/ URL.
313 */
314 $filtered = apply_filters( 'xspeed_mcp_resource_metadata_url', $pretty );
315 if ( is_string( $filtered ) && '' !== $filtered && $filtered !== $pretty ) {
316 return $filtered;
317 }
318
319 // Rewrites absent (plain permalinks, or a flush that never landed)
320 // means the pretty URL cannot resolve at all — use the fallback.
321 if ( ! McpModule::wellknown_rewrites_active() ) {
322 return Mcp_Pairing::absolute( rest_url( McpModule::NS . '/mcp/.well-known/oauth-protected-resource' ) );
323 }
324
325 return $pretty;
326 }
327
328 /** Pull the token from Bearer or X-XSpeed-MCP-Token, Bearer wins. */
329 private static function extract_token( \WP_REST_Request $request ): string {
330 $auth = $request->get_header( 'authorization' );
331 if ( is_string( $auth ) && preg_match( '/^Bearer\s+(.+)$/i', trim( $auth ), $m ) ) {
332 return trim( $m[1] );
333 }
334 $header = $request->get_header( Mcp_Auth::TOKEN_HEADER );
335 return is_string( $header ) ? trim( $header ) : '';
336 }
337
338 // -- JSON-RPC envelope helpers --
339
340 /** Build a JSON-RPC success envelope. */
341 private static function result( $id, $result ): array {
342 return array(
343 'jsonrpc' => '2.0',
344 'id' => $id,
345 'result' => $result,
346 );
347 }
348
349 /** Build a JSON-RPC error envelope (for a single message). */
350 private static function error( $id, int $code, string $message ): array {
351 return array(
352 'jsonrpc' => '2.0',
353 'id' => $id,
354 'error' => array(
355 'code' => $code,
356 'message' => $message,
357 ),
358 );
359 }
360
361 /** Build a top-level error WP_REST_Response with an HTTP status. */
362 private static function error_response( $id, int $code, string $message, int $http ): \WP_REST_Response {
363 return new \WP_REST_Response( self::error( $id, $code, $message ), $http );
364 }
365 }
366