PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.3
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.3
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 1.1.5 1.1.6 1.1.7 1.1.8 All 29 releases
xspeed / includes / modules / Mcp / Mcp_Server.php

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

331 lines 11.0 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 $msg = json_decode( $raw, true );
72
73 if ( null === $msg && JSON_ERROR_NONE !== json_last_error() ) {
74 return self::error_response( null, self::PARSE_ERROR, 'Parse error: body is not valid JSON.', 400 );
75 }
76
77 // Batched requests: an array of messages. Handle each; drop
78 // notification (id-less) responses per JSON-RPC.
79 if ( is_array( $msg ) && array_key_exists( 0, $msg ) ) {
80 $responses = array();
81 foreach ( $msg as $one ) {
82 $r = self::dispatch( is_array( $one ) ? $one : array() );
83 if ( null !== $r ) {
84 $responses[] = $r;
85 }
86 }
87 // All notifications → 202 Accepted, empty body.
88 if ( empty( $responses ) ) {
89 return new \WP_REST_Response( null, 202 );
90 }
91 return new \WP_REST_Response( $responses, 200 );
92 }
93
94 if ( ! is_array( $msg ) ) {
95 return self::error_response( null, self::INVALID_REQUEST, 'Invalid request.', 400 );
96 }
97
98 $response = self::dispatch( $msg );
99 if ( null === $response ) {
100 // Notification — no response body, 202 Accepted.
101 return new \WP_REST_Response( null, 202 );
102 }
103 return new \WP_REST_Response( $response, 200 );
104 }
105
106 /**
107 * Dispatch a single JSON-RPC message. Returns the response array, or
108 * null for notifications (messages with no `id`).
109 *
110 * @param array $msg Decoded JSON-RPC message.
111 * @return array|null
112 */
113 private static function dispatch( array $msg ) {
114 $method = isset( $msg['method'] ) ? (string) $msg['method'] : '';
115 $id = $msg['id'] ?? null;
116 $params = isset( $msg['params'] ) && is_array( $msg['params'] ) ? $msg['params'] : array();
117
118 // Notifications (no id) get acknowledged with no response.
119 $is_notification = ! array_key_exists( 'id', $msg );
120
121 switch ( $method ) {
122 case 'initialize':
123 return self::result(
124 $id,
125 array(
126 'protocolVersion' => self::PROTOCOL_VERSION,
127 'capabilities' => array(
128 'tools' => array( 'listChanged' => false ),
129 ),
130 'serverInfo' => array(
131 'name' => 'xspeed',
132 'version' => defined( 'XSPEED_VERSION' ) ? XSPEED_VERSION : '1.0.0',
133 ),
134 )
135 );
136
137 case 'ping':
138 return self::result( $id, (object) array() );
139
140 case 'tools/list':
141 return self::result( $id, array( 'tools' => Mcp_Tools::list() ) );
142
143 case 'tools/call':
144 return self::call_tool( $id, $params );
145
146 default:
147 // notifications/initialized, notifications/cancelled, etc.
148 if ( $is_notification || 0 === strpos( $method, 'notifications/' ) ) {
149 return null;
150 }
151 return self::error( $id, self::METHOD_NOT_FOUND, 'Method not found: ' . $method );
152 }
153 }
154
155 /**
156 * Execute a tools/call request and wrap the result in MCP content.
157 *
158 * @param mixed $id JSON-RPC id.
159 * @param array $params { name:string, arguments:array }.
160 * @return array
161 */
162 private static function call_tool( $id, array $params ) {
163 $name = isset( $params['name'] ) ? (string) $params['name'] : '';
164 $args = isset( $params['arguments'] ) && is_array( $params['arguments'] ) ? $params['arguments'] : array();
165
166 if ( '' === $name ) {
167 return self::error( $id, self::INVALID_PARAMS, 'Missing tool name.' );
168 }
169
170 Mcp_Tools::set_channel( 'mcp' );
171 $result = Mcp_Tools::invoke( $name, $args );
172
173 if ( is_wp_error( $result ) ) {
174 // Tool-level failure is reported as a successful JSON-RPC
175 // response with isError=true (per MCP), so the model can read
176 // the message rather than the transport swallowing it.
177 return self::result(
178 $id,
179 array(
180 'content' => array(
181 array(
182 'type' => 'text',
183 'text' => $result->get_error_message(),
184 ),
185 ),
186 'isError' => true,
187 )
188 );
189 }
190
191 // A Cli_Bridge-backed tool reports command failure as ok:false inside
192 // the payload. Without this, the envelope said isError:false and an
193 // agent read "Could not connect to Redis" as a success.
194 $failed = is_array( $result ) && array_key_exists( 'ok', $result ) && false === $result['ok'];
195
196 return self::result(
197 $id,
198 array(
199 'content' => array(
200 array(
201 'type' => 'text',
202 'text' => wp_json_encode( $result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ),
203 ),
204 ),
205 'isError' => $failed,
206 )
207 );
208 }
209
210 // -- Auth --
211
212 /**
213 * Validate the connection token from either the Authorization: Bearer
214 * header or X-XSpeed-MCP-Token. Reuses Mcp_Auth's constant-time check
215 * against the stored site_token.
216 *
217 * @param \WP_REST_Request $request Incoming request.
218 * @return bool
219 */
220 private static function authorize( \WP_REST_Request $request ): bool {
221 $presented = self::extract_token( $request );
222 if ( '' === $presented ) {
223 return false;
224 }
225
226 // Path 1: the static per-site pairing token (Mcp_Pairing). Leave the
227 // tool scope override cleared so Mcp_Tools defers to the pairing
228 // token's own read-only scope. Credential writes over the pairing token
229 // stay gated on the xspeed_mcp_allow_credential_writes filter (off by
230 // default) — clear the configure override so that default applies. (#116)
231 $stored = Mcp_Pairing::site_token();
232 if ( '' !== $stored && hash_equals( $stored, $presented ) ) {
233 Mcp_Tools::set_read_only_override( null );
234 Mcp_Tools::set_configure_override( null );
235 return true;
236 }
237
238 // Path 2: an OAuth 2.1 access token minted by Mcp_OAuth. Its own
239 // granted scope decides read-only AND whether it may write credentials
240 // (the explicit, opt-in `configure` scope), independent of any pairing
241 // token.
242 $grant = Mcp_OAuth::validate_token( $presented );
243 if ( null !== $grant ) {
244 Mcp_Tools::set_read_only_override( Mcp_OAuth::scope_is_read_only( $grant['scope'] ) );
245 Mcp_Tools::set_configure_override( Mcp_OAuth::scope_allows_configure( $grant['scope'] ) );
246 return true;
247 }
248
249 return false;
250 }
251
252 /**
253 * The RFC 9728 WWW-Authenticate challenge value. Points the client at
254 * this site's protected-resource metadata so an OAuth-capable client
255 * can discover the authorization server and begin the flow.
256 */
257 private static function challenge_header(): string {
258 return sprintf( 'Bearer resource_metadata="%s"', self::metadata_url() );
259 }
260
261 /**
262 * Where this site actually serves its protected-resource metadata.
263 *
264 * Prefers the canonical /.well-known/ URL, but many hosts own that prefix
265 * for ACME/Let's Encrypt and answer it before WordPress runs — the client
266 * then follows a pointer to a 404 (or a redirect to the homepage) and the
267 * OAuth flow dead-ends. RFC 9728 allows a single resource_metadata value,
268 * so when the pretty path is not ours to serve we advertise the /wp-json
269 * fallback, which no ACME tooling claims.
270 */
271 private static function metadata_url(): string {
272 $pretty = home_url( '/.well-known/oauth-protected-resource' );
273
274 /**
275 * Filter the advertised protected-resource metadata URL.
276 *
277 * @param string $pretty The canonical /.well-known/ URL.
278 */
279 $filtered = apply_filters( 'xspeed_mcp_resource_metadata_url', $pretty );
280 if ( is_string( $filtered ) && '' !== $filtered && $filtered !== $pretty ) {
281 return $filtered;
282 }
283
284 // Rewrites absent (plain permalinks, or a flush that never landed)
285 // means the pretty URL cannot resolve at all — use the fallback.
286 if ( ! McpModule::wellknown_rewrites_active() ) {
287 return rest_url( McpModule::NS . '/mcp/.well-known/oauth-protected-resource' );
288 }
289
290 return $pretty;
291 }
292
293 /** Pull the token from Bearer or X-XSpeed-MCP-Token, Bearer wins. */
294 private static function extract_token( \WP_REST_Request $request ): string {
295 $auth = $request->get_header( 'authorization' );
296 if ( is_string( $auth ) && preg_match( '/^Bearer\s+(.+)$/i', trim( $auth ), $m ) ) {
297 return trim( $m[1] );
298 }
299 $header = $request->get_header( Mcp_Auth::TOKEN_HEADER );
300 return is_string( $header ) ? trim( $header ) : '';
301 }
302
303 // -- JSON-RPC envelope helpers --
304
305 /** Build a JSON-RPC success envelope. */
306 private static function result( $id, $result ): array {
307 return array(
308 'jsonrpc' => '2.0',
309 'id' => $id,
310 'result' => $result,
311 );
312 }
313
314 /** Build a JSON-RPC error envelope (for a single message). */
315 private static function error( $id, int $code, string $message ): array {
316 return array(
317 'jsonrpc' => '2.0',
318 'id' => $id,
319 'error' => array(
320 'code' => $code,
321 'message' => $message,
322 ),
323 );
324 }
325
326 /** Build a top-level error WP_REST_Response with an HTTP status. */
327 private static function error_response( $id, int $code, string $message, int $http ): \WP_REST_Response {
328 return new \WP_REST_Response( self::error( $id, $code, $message ), $http );
329 }
330 }
331