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
← All changes | includes/modules/Mcp/McpModule.php +76 -1 1.3.4 → 1.3.7 View file →
@@ -147,10 +147,11 @@
147 147 public function ui_metadata(): array {
148 148 return array(
149 149 'label' => __( 'MCP Server', 'xspeed' ),
150 150 'icon' => 'Sparkles',
151 - 'description' => __( 'Let Claude or another AI agent run this site — purge, check stats, change settings. This is the only thing you connect an AI to, and it is free with no API key.', 'xspeed' ),
151 + 'description' => __( 'Let Claude or another AI assistant clear the cache, check stats and change settings.', 'xspeed' ),
152 152 'custom_panel' => 'McpPanel',
153 + 'group' => 'ai-agents',
153 154 );
154 155 }
155 156
156 157 /**
@@ -171,9 +172,18 @@
171 172 }
172 173
173 174 public function boot(): void {
174 175 add_action( 'rest_api_init', array( $this, 'register_rest' ) );
176 + add_action( Mcp_Pairing::TOKEN_CHANGED_ACTION, array( Mcp_Hub::class, 'on_token_changed' ) );
175 177
178 + // The only place the body cap can run before WordPress decodes it.
179 + // WP_REST_Server::dispatch() fires rest_pre_dispatch, and only after
180 + // that calls has_valid_params() — which json_decode()s the whole body
181 + // for any application/json request, ahead of the permission callback
182 + // and the handler. A cap inside a handler is therefore a second line,
183 + // not the bound it reads like.
184 + add_filter( 'rest_pre_dispatch', array( $this, 'cap_request_body' ), 10, 3 );
185 +
176 186 // Pretty per-site endpoint: /xspeed/mcp → MCP JSON-RPC handler.
177 187 // `wp_loaded`, not `init`: add_rewrite() decides whether to claim the
178 188 // root discovery URL by looking at the rewrite table, and on `init` that
179 189 // view is incomplete -- a sibling MCP plugin hooked at the same priority
@@ -762,8 +772,10 @@
762 772 if ( null === $result ) {
763 773 status_header( 403 );
764 774 echo wp_json_encode( array( 'error' => 'invalid_or_expired_attach_request' ) );
765 775 } else {
776 + // The Hub needs only the credential, as on the REST route.
777 + unset( $result['user_id'] );
766 778 status_header( 200 );
767 779 echo wp_json_encode( $result );
768 780 }
769 781 exit;
@@ -1475,8 +1487,61 @@
1475 1487 return $response;
1476 1488 }
1477 1489
1478 1490 /**
1491 + * Reject an over-sized MCP request body before WordPress decodes it.
1492 + *
1493 + * `rest_pre_dispatch` is the last hook that runs before
1494 + * `WP_REST_Server::dispatch()` calls `has_valid_params()`, and that is
1495 + * what `json_decode()`s the body — before the permission callback, so an
1496 + * unauthenticated caller already pays for the decode. Capping here is the
1497 + * difference between reading a length and parsing megabytes of JSON.
1498 + *
1499 + * Refusing is not enough on its own. Core reads request params again on
1500 + * the way out — `rest_filter_response_fields()` on `rest_post_dispatch`
1501 + * looks up `_fields` — and for an `application/json` request that lookup
1502 + * runs `parse_json_params()` over whatever body is still attached. So a
1503 + * refused body is also emptied, and the 413 goes out with nothing left to
1504 + * decode. QA measured a refused 3 MB body peaking near 90 MB without this.
1505 + *
1506 + * Scoped to the two routes that carry tool payloads, compared
1507 + * case-insensitively: `WP_REST_Server::match_request_to_handler()` matches
1508 + * routes with the `i` flag, so `/XSPEED/v1/MCP` reaches the same handler
1509 + * and has to meet the same cap. Returning null leaves the request alone,
1510 + * which is what this filter does for everything else.
1511 + *
1512 + * @param mixed $result A short-circuit response, if one is set.
1513 + * @param mixed $server Unused; the REST server instance.
1514 + * @param \WP_REST_Request $request Incoming request.
1515 + * @return mixed Null to continue, or a WP_Error to refuse.
1516 + */
1517 + public function cap_request_body( $result, $server = null, $request = null ) {
1518 + if ( null !== $result || ! $request instanceof \WP_REST_Request ) {
1519 + return $result;
1520 + }
1521 +
1522 + $route = strtolower( (string) $request->get_route() );
1523 + $mcp = '/' . self::NS . '/mcp';
1524 + if ( $route !== $mcp && 0 !== strpos( $route, $mcp . '/tool/' ) ) {
1525 + return $result;
1526 + }
1527 +
1528 + if ( strlen( (string) $request->get_body() ) <= Mcp_Tools::MAX_TOOL_BODY_BYTES ) {
1529 + return $result;
1530 + }
1531 +
1532 + // Drop the body before refusing. Nothing downstream needs it, and
1533 + // core's response pipeline would otherwise json_decode() it anyway.
1534 + $request->set_body( '' );
1535 +
1536 + return new \WP_Error(
1537 + 'xspeed_mcp_tool_payload_too_large',
1538 + __( 'The MCP tool payload is too large.', 'xspeed' ),
1539 + array( 'status' => 413 )
1540 + );
1541 + }
1542 +
1543 + /**
1479 1544 * Token-authenticated tool route for the hosted broker. Maps a broker
1480 1545 * tool call (e.g. GET /mcp/tool/get_cache_status) onto the shared
1481 1546 * Mcp_Tools catalog, so the broker path and the JSON-RPC path never
1482 1547 * drift. GET params + JSON body both feed the tool's arguments.
@@ -1482,8 +1547,18 @@
1482 1547 * drift. GET params + JSON body both feed the tool's arguments.
1483 1548 */
1484 1549 public function rest_tool( \WP_REST_Request $request ) {
1485 1550 $tool = (string) $request->get_param( 'tool' );
1551 + // Second line behind cap_request_body(). This one still matters: the
1552 + // pretty front-door path builds its own WP_REST_Request and calls the
1553 + // handler without going through WP_REST_Server::dispatch() at all.
1554 + if ( strlen( $request->get_body() ) > Mcp_Tools::MAX_TOOL_BODY_BYTES ) {
1555 + return new \WP_Error(
1556 + 'xspeed_mcp_tool_payload_too_large',
1557 + __( 'The MCP tool payload is too large.', 'xspeed' ),
1558 + array( 'status' => 413 )
1559 + );
1560 + }
1486 1561 $args = $request->get_json_params();
1487 1562 if ( ! is_array( $args ) ) {
1488 1563 $args = array();
1489 1564 }