| @@ -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 | } |