| @@ -44,8 +44,14 @@ | ||
| 44 | 44 | defined( 'ABSPATH' ) || exit; |
| 45 | 45 | |
| 46 | 46 | final class McpModule extends Module { |
| 47 | 47 | |
| 48 | + /** | |
| 49 | + * The stylesheet of the standalone consent pages: the OAuth authorize | |
| 50 | + * screen here and the Hub connect screen (Mcp_Hub_Connect). | |
| 51 | + */ | |
| 52 | + public const CONSENT_CSS = 'body{font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0f172a;color:#e2e8f0;margin:0;display:flex;min-height:100vh;align-items:center;justify-content:center}.card{background:#1e293b;border:1px solid #334155;border-radius:16px;max-width:440px;padding:32px;box-shadow:0 10px 40px rgba(0,0,0,.4)}h1{font-size:20px;margin:0 0 4px}.sub{color:#94a3b8;font-size:13px;margin:0 0 24px}.row{display:flex;justify-content:space-between;padding:10px 0;border-bottom:1px solid #334155;font-size:13px}.row span:first-child{color:#94a3b8}.row span:last-child{font-weight:600;text-align:right;max-width:60%;word-break:break-word}.actions{display:flex;gap:12px;margin-top:24px}button{flex:1;padding:12px;border-radius:10px;border:0;font-size:14px;font-weight:600;cursor:pointer}.approve{background:#f5cd47;color:#1b2533}.deny{background:transparent;color:#94a3b8;border:1px solid #334155}'; | |
| 53 | + | |
| 48 | 54 | public const SLUG = 'mcp'; |
| 49 | 55 | public const TIER = self::TIER_FREE; |
| 50 | 56 | public const VERSION = '1.0.0'; |
| 51 | 57 | |
| @@ -64,8 +70,9 @@ | ||
| 64 | 70 | public const REWRITE_RULES = array( |
| 65 | 71 | '^xspeed/mcp/([a-f0-9]{64})/?$', |
| 66 | 72 | '^xspeed/mcp/?$', |
| 67 | 73 | '^xspeed/mcp/attach/?$', |
| 74 | + '^xspeed/mcp/connect-info/?$', | |
| 68 | 75 | // OAuth discovery. RFC 9728 §3.1 / RFC 8414 §3.1 put the |
| 69 | 76 | // `.well-known` segment BEFORE the resource/issuer path, and both of |
| 70 | 77 | // our identifiers are /xspeed/mcp — so this pair of URLs, and only |
| 71 | 78 | // this pair, is ours. It names our own path explicitly: a catch-all |
| @@ -143,14 +150,18 @@ | ||
| 143 | 150 | |
| 144 | 151 | /** Query var flagging the pretty /xspeed/mcp/attach callback. */ |
| 145 | 152 | private const ATTACH_QUERY_VAR = 'xspeed_mcp_attach'; |
| 146 | 153 | |
| 154 | + /** Query var flagging the pretty /xspeed/mcp/connect-info document (xspeed-hub#307). */ | |
| 155 | + private const CONNECT_INFO_QUERY_VAR = 'xspeed_mcp_connect_info'; | |
| 156 | + | |
| 147 | 157 | public function ui_metadata(): array { |
| 148 | 158 | return array( |
| 149 | 159 | 'label' => __( 'MCP Server', 'xspeed' ), |
| 150 | 160 | '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' ), | |
| 161 | + 'description' => __( 'Let Claude or another AI assistant clear the cache, check stats and change settings.', 'xspeed' ), | |
| 152 | 162 | 'custom_panel' => 'McpPanel', |
| 163 | + 'group' => 'ai-agents', | |
| 153 | 164 | ); |
| 154 | 165 | } |
| 155 | 166 | |
| 156 | 167 | /** |
| @@ -162,8 +173,31 @@ | ||
| 162 | 173 | return array(); |
| 163 | 174 | } |
| 164 | 175 | |
| 165 | 176 | /** |
| 177 | + * The pairing state, declared so a settings write cannot delete it. | |
| 178 | + * | |
| 179 | + * `Mcp_Pairing` keeps its credentials in this module's settings option, | |
| 180 | + * and the schema above is empty — so `Settings_Manager::get()` strips | |
| 181 | + * every one of these keys, and an `update()` for this slug then writes | |
| 182 | + * that stripped array back. The connection token, the scopes and the | |
| 183 | + * connected flag all disappear in a single save, and the only visible | |
| 184 | + * result is a site that has silently lost MCP access while the Hub still | |
| 185 | + * lists it as attached. | |
| 186 | + * | |
| 187 | + * Nothing offers a settings form for this module, which is why it went | |
| 188 | + * unnoticed, but `update()` takes its slug from data on three paths that | |
| 189 | + * do not: a recommendation action, an optimize plan, and the MCP | |
| 190 | + * `update_settings` tool. `preserved_keys()` is the mechanism for exactly | |
| 191 | + * this — out-of-schema keys a schema-driven save must carry through. | |
| 192 | + * | |
| 193 | + * @return string[] | |
| 194 | + */ | |
| 195 | + public function preserved_keys(): array { | |
| 196 | + return array( 'site_token', 'connection_token', 'connected', 'connected_at', 'scopes' ); | |
| 197 | + } | |
| 198 | + | |
| 199 | + /** | |
| 166 | 200 | * All MCP routes register directly (see class docblock). Returning an |
| 167 | 201 | * empty array keeps Rest_Manager out of the token-auth path entirely. |
| 168 | 202 | */ |
| 169 | 203 | public function rest_routes(): array { |
| @@ -171,9 +205,18 @@ | ||
| 171 | 205 | } |
| 172 | 206 | |
| 173 | 207 | public function boot(): void { |
| 174 | 208 | add_action( 'rest_api_init', array( $this, 'register_rest' ) ); |
| 209 | + add_action( Mcp_Pairing::TOKEN_CHANGED_ACTION, array( Mcp_Hub::class, 'on_token_changed' ) ); | |
| 175 | 210 | |
| 211 | + // The only place the body cap can run before WordPress decodes it. | |
| 212 | + // WP_REST_Server::dispatch() fires rest_pre_dispatch, and only after | |
| 213 | + // that calls has_valid_params() — which json_decode()s the whole body | |
| 214 | + // for any application/json request, ahead of the permission callback | |
| 215 | + // and the handler. A cap inside a handler is therefore a second line, | |
| 216 | + // not the bound it reads like. | |
| 217 | + add_filter( 'rest_pre_dispatch', array( $this, 'cap_request_body' ), 10, 3 ); | |
| 218 | + | |
| 176 | 219 | // Pretty per-site endpoint: /xspeed/mcp → MCP JSON-RPC handler. |
| 177 | 220 | // `wp_loaded`, not `init`: add_rewrite() decides whether to claim the |
| 178 | 221 | // root discovery URL by looking at the rewrite table, and on `init` that |
| 179 | 222 | // view is incomplete -- a sibling MCP plugin hooked at the same priority |
| @@ -207,8 +250,12 @@ | ||
| 207 | 250 | // the account email + the SAME signed nonce we minted. We verify our own |
| 208 | 251 | // nonce and mark this admin attached — no server-to-server callback |
| 209 | 252 | // needed, so it works for local/firewalled sites too. |
| 210 | 253 | add_action( 'admin_init', array( $this, 'maybe_handle_hub_return' ) ); |
| 254 | + // The Hub connect consent page (xspeed-hub#307). Priority 1: it emits a | |
| 255 | + // standalone page and exits before anything else on admin_init runs. | |
| 256 | + add_action( 'admin_init', array( Mcp_Hub_Connect::class, 'maybe_handle_page' ), 1 ); | |
| 257 | + add_action( 'admin_menu', array( Mcp_Hub_Connect::class, 'register_page' ) ); | |
| 211 | 258 | |
| 212 | 259 | // An attached admin who is DELETED (or removed from the blog) never |
| 213 | 260 | // runs disconnect(), so the site-level attached mirror would report |
| 214 | 261 | // hub:true forever. deleted_user fires after both wp_delete_user() |
| @@ -252,12 +299,13 @@ | ||
| 252 | 299 | } |
| 253 | 300 | |
| 254 | 301 | // Verify OUR own signed nonce (proves the round-trip went through the |
| 255 | 302 | // Hub with a token we minted), then record the connection. |
| 303 | + // Signature only: the Hub has already spent this nonce on the attach | |
| 304 | + // callback, and recording the link needs no credential. | |
| 256 | 305 | if ( '' !== $nonce ) { |
| 257 | - $verified = Mcp_Hub::verify_attach_nonce( $nonce ); | |
| 258 | - if ( null !== $verified ) { | |
| 259 | - $uid = isset( $verified['user_id'] ) ? (int) $verified['user_id'] : get_current_user_id(); | |
| 306 | + $uid = Mcp_Hub::check_attach_nonce( $nonce ); | |
| 307 | + if ( null !== $uid ) { | |
| 260 | 308 | Mcp_Hub::mark_attached( $email, $uid ?: null ); |
| 261 | 309 | } |
| 262 | 310 | } |
| 263 | 311 | |
| @@ -320,8 +368,11 @@ | ||
| 320 | 368 | // own rewrite (consistent with the MCP URL, survives hosts that block |
| 321 | 369 | // /wp-json). Placed BEFORE the token rule would never match "attach" |
| 322 | 370 | // (that rule requires 64 hex chars), so ordering is safe. |
| 323 | 371 | add_rewrite_rule( '^xspeed/mcp/attach/?$', 'index.php?' . self::ATTACH_QUERY_VAR . '=1', 'top' ); |
| 372 | + // The Hub connect document, outside /wp-json for the same reason: a | |
| 373 | + // site whose security plugin refuses anonymous REST still answers it. | |
| 374 | + add_rewrite_rule( '^xspeed/mcp/connect-info/?$', 'index.php?' . self::CONNECT_INFO_QUERY_VAR . '=1', 'top' ); | |
| 324 | 375 | |
| 325 | 376 | // OAuth discovery documents. RFC 9728 §3.1 / RFC 8414 §3.1 place the |
| 326 | 377 | // `.well-known` segment BEFORE the resource/issuer path, and both of |
| 327 | 378 | // our canonical identifiers are the MCP endpoint URL, so our |
| @@ -542,9 +593,9 @@ | ||
| 542 | 593 | if ( ! is_string( $target ) ) { |
| 543 | 594 | return false; |
| 544 | 595 | } |
| 545 | 596 | |
| 546 | - foreach ( array( self::QUERY_VAR, self::TOKEN_QUERY_VAR, self::WELLKNOWN_QUERY_VAR, self::AUTHORIZE_QUERY_VAR, self::ATTACH_QUERY_VAR ) as $var ) { | |
| 597 | + foreach ( array( self::QUERY_VAR, self::TOKEN_QUERY_VAR, self::WELLKNOWN_QUERY_VAR, self::AUTHORIZE_QUERY_VAR, self::ATTACH_QUERY_VAR, self::CONNECT_INFO_QUERY_VAR ) as $var ) { | |
| 547 | 598 | if ( false !== strpos( $target, $var . '=' ) ) { |
| 548 | 599 | return true; |
| 549 | 600 | } |
| 550 | 601 | } |
| @@ -601,8 +652,9 @@ | ||
| 601 | 652 | $vars[] = self::TOKEN_QUERY_VAR; |
| 602 | 653 | $vars[] = self::WELLKNOWN_QUERY_VAR; |
| 603 | 654 | $vars[] = self::AUTHORIZE_QUERY_VAR; |
| 604 | 655 | $vars[] = self::ATTACH_QUERY_VAR; |
| 656 | + $vars[] = self::CONNECT_INFO_QUERY_VAR; | |
| 605 | 657 | return $vars; |
| 606 | 658 | } |
| 607 | 659 | |
| 608 | 660 | /** |
| @@ -749,15 +801,30 @@ | ||
| 749 | 801 | echo wp_json_encode( $data ); |
| 750 | 802 | exit; |
| 751 | 803 | } |
| 752 | 804 | |
| 805 | + // Pretty Hub connect document: /xspeed/mcp/connect-info (xspeed-hub#307). | |
| 806 | + if ( ! empty( $wp->query_vars[ self::CONNECT_INFO_QUERY_VAR ] ) ) { | |
| 807 | + header( 'Content-Type: application/json; charset=utf-8' ); | |
| 808 | + header( 'Cache-Control: no-store' ); | |
| 809 | + status_header( 200 ); | |
| 810 | + echo wp_json_encode( Mcp_Hub_Connect::connect_info() ); | |
| 811 | + exit; | |
| 812 | + } | |
| 813 | + | |
| 753 | 814 | // Pretty attach-callback: /xspeed/mcp/attach. The hub POSTs the signed |
| 754 | 815 | // nonce; we verify it and return this site's URL + token. Auth is the |
| 755 | 816 | // nonce itself (admin-minted, HMAC-signed), so no credential needed. |
| 756 | 817 | if ( ! empty( $wp->query_vars[ self::ATTACH_QUERY_VAR ] ) ) { |
| 757 | - $body = json_decode( (string) file_get_contents( 'php://input' ), true ); | |
| 758 | - $nonce = is_array( $body ) && isset( $body['nonce'] ) ? (string) $body['nonce'] : ''; | |
| 759 | - $result = Mcp_Hub::verify_attach_nonce( $nonce ); | |
| 818 | + $body = json_decode( (string) file_get_contents( 'php://input' ), true ); | |
| 819 | + $body = is_array( $body ) ? $body : array(); | |
| 820 | + $field = static fn( string $k ): string => isset( $body[ $k ] ) && is_string( $body[ $k ] ) ? $body[ $k ] : ''; | |
| 821 | + $result = self::attach_result( $field( 'nonce' ), $field( 'code' ), $field( 'code_verifier' ) ); | |
| 822 | + // A Hub-started connect lands the browser on the Hub, not here, so | |
| 823 | + // this call is the only place the per-user link can be recorded. | |
| 824 | + if ( null !== $result && '' !== $field( 'code' ) ) { | |
| 825 | + Mcp_Hub::mark_attached( sanitize_email( $field( 'account_email' ) ), (int) $result['user_id'] ?: null ); | |
| 826 | + } | |
| 760 | 827 | header( 'Content-Type: application/json; charset=utf-8' ); |
| 761 | 828 | header( 'Cache-Control: no-store' ); |
| 762 | 829 | if ( null === $result ) { |
| 763 | 830 | status_header( 403 ); |
| @@ -762,8 +829,10 @@ | ||
| 762 | 829 | if ( null === $result ) { |
| 763 | 830 | status_header( 403 ); |
| 764 | 831 | echo wp_json_encode( array( 'error' => 'invalid_or_expired_attach_request' ) ); |
| 765 | 832 | } else { |
| 833 | + // The Hub needs only the credential, as on the REST route. | |
| 834 | + unset( $result['user_id'] ); | |
| 766 | 835 | status_header( 200 ); |
| 767 | 836 | echo wp_json_encode( $result ); |
| 768 | 837 | } |
| 769 | 838 | exit; |
| @@ -974,10 +1043,28 @@ | ||
| 974 | 1043 | array( |
| 975 | 1044 | 'methods' => 'POST', |
| 976 | 1045 | 'callback' => array( $this, 'rest_hub_disconnect' ), |
| 977 | 1046 | 'permission_callback' => array( $this, 'admin_permission' ), |
| 1047 | + 'args' => array( | |
| 1048 | + 'acknowledge' => array( | |
| 1049 | + 'type' => 'boolean', | |
| 1050 | + 'default' => false, | |
| 1051 | + 'description' => 'Continue even though Mcp_Hub::disconnect_blockers() reported a reason not to. The caller has shown those reasons to a human.', | |
| 1052 | + ), | |
| 1053 | + ), | |
| 978 | 1054 | ) |
| 979 | 1055 | ); |
| 1056 | + // Hub connect discovery (xspeed-hub#307): public, so the Hub can ask | |
| 1057 | + // before sending anyone to the consent page. Names no secret. | |
| 1058 | + register_rest_route( | |
| 1059 | + self::NS, | |
| 1060 | + '/hub/connect-info', | |
| 1061 | + array( | |
| 1062 | + 'methods' => 'GET', | |
| 1063 | + 'callback' => array( $this, 'rest_hub_connect_info' ), | |
| 1064 | + 'permission_callback' => '__return_true', | |
| 1065 | + ) | |
| 1066 | + ); | |
| 980 | 1067 | // OAuth-attach callback: the hub calls this with the signed nonce the |
| 981 | 1068 | // plugin issued. Auth is the nonce itself (no pre-shared token), so |
| 982 | 1069 | // permission_callback is open — the handler validates the nonce. |
| 983 | 1070 | register_rest_route( |
| @@ -987,13 +1074,25 @@ | ||
| 987 | 1074 | 'methods' => 'POST', |
| 988 | 1075 | 'callback' => array( $this, 'rest_hub_attach_callback' ), |
| 989 | 1076 | 'permission_callback' => '__return_true', |
| 990 | 1077 | 'args' => array( |
| 991 | - 'nonce' => array( | |
| 1078 | + // One of: the signed nonce (plugin-started attach), or a | |
| 1079 | + // code plus its PKCE verifier (Hub-started connect, #307). | |
| 1080 | + 'nonce' => array( | |
| 992 | 1081 | 'type' => 'string', |
| 993 | - 'required' => true, | |
| 1082 | + 'required' => false, | |
| 994 | 1083 | 'description' => 'The signed attach nonce the plugin issued.', |
| 995 | 1084 | ), |
| 1085 | + 'code' => array( | |
| 1086 | + 'type' => 'string', | |
| 1087 | + 'required' => false, | |
| 1088 | + 'description' => 'The single-use code from the Hub connect consent page.', | |
| 1089 | + ), | |
| 1090 | + 'code_verifier' => array( | |
| 1091 | + 'type' => 'string', | |
| 1092 | + 'required' => false, | |
| 1093 | + 'description' => 'The PKCE verifier for that code.', | |
| 1094 | + ), | |
| 996 | 1095 | ), |
| 997 | 1096 | ) |
| 998 | 1097 | ); |
| 999 | 1098 | |
| @@ -1278,19 +1377,64 @@ | ||
| 1278 | 1377 | return rest_ensure_response( Mcp_Hub::mark_attached( $email ) ); |
| 1279 | 1378 | } |
| 1280 | 1379 | |
| 1281 | 1380 | /** |
| 1282 | - * POST /mcp/hub/disconnect — clear the local hub-link bookkeeping. | |
| 1381 | + * POST /mcp/hub/disconnect — tell the hub to drop this admin's link and | |
| 1382 | + * clear the local bookkeeping. | |
| 1283 | 1383 | * |
| 1284 | - * @param \WP_REST_Request $request Unused. | |
| 1285 | - * @return \WP_REST_Response | |
| 1384 | + * Answers 409 while something reports a reason not to disconnect and the | |
| 1385 | + * request did not send `acknowledge`. The blockers travel in the error | |
| 1386 | + * data so a non-UI client gets the same reason a human would read. | |
| 1387 | + * | |
| 1388 | + * @param \WP_REST_Request $request Carries the optional `acknowledge` flag. | |
| 1389 | + * @return \WP_REST_Response|\WP_Error | |
| 1286 | 1390 | */ |
| 1287 | 1391 | public function rest_hub_disconnect( \WP_REST_Request $request ) { |
| 1288 | - unset( $request ); | |
| 1289 | - return rest_ensure_response( Mcp_Hub::disconnect() ); | |
| 1392 | + $result = Mcp_Hub::disconnect( (bool) $request->get_param( 'acknowledge' ) ); | |
| 1393 | + | |
| 1394 | + if ( ! empty( $result['blocked'] ) ) { | |
| 1395 | + $blockers = isset( $result['blockers'] ) && is_array( $result['blockers'] ) | |
| 1396 | + ? $result['blockers'] | |
| 1397 | + : array(); | |
| 1398 | + $reasons = trim( implode( ' ', array_column( $blockers, 'message' ) ) ); | |
| 1399 | + | |
| 1400 | + return new \WP_Error( | |
| 1401 | + 'xspeed_hub_disconnect_blocked', | |
| 1402 | + '' !== $reasons | |
| 1403 | + ? $reasons | |
| 1404 | + : __( 'Disconnecting is blocked while this site depends on the hub connection.', 'xspeed' ), | |
| 1405 | + array( | |
| 1406 | + 'status' => 409, | |
| 1407 | + 'blockers' => $blockers, | |
| 1408 | + ) | |
| 1409 | + ); | |
| 1410 | + } | |
| 1411 | + | |
| 1412 | + return rest_ensure_response( $result ); | |
| 1290 | 1413 | } |
| 1291 | 1414 | |
| 1415 | + /** GET /hub/connect-info — whether this site supports connecting from the Hub. */ | |
| 1416 | + public function rest_hub_connect_info(): \WP_REST_Response { | |
| 1417 | + return rest_ensure_response( Mcp_Hub_Connect::connect_info() ); | |
| 1418 | + } | |
| 1419 | + | |
| 1292 | 1420 | /** |
| 1421 | + * Check an attach callback: a nonce (plugin-started) or a code with its | |
| 1422 | + * PKCE verifier (Hub-started, xspeed-hub#307). Null when neither holds. | |
| 1423 | + * | |
| 1424 | + * @return array{site_url:string,site_token:string,user_id:int}|null | |
| 1425 | + */ | |
| 1426 | + private static function attach_result( string $nonce, string $code, string $verifier ): ?array { | |
| 1427 | + if ( '' !== $nonce ) { | |
| 1428 | + return Mcp_Hub::verify_attach_nonce( $nonce ); | |
| 1429 | + } | |
| 1430 | + if ( '' !== $code ) { | |
| 1431 | + return Mcp_Hub_Connect::redeem_code( $code, $verifier ); | |
| 1432 | + } | |
| 1433 | + return null; | |
| 1434 | + } | |
| 1435 | + | |
| 1436 | + /** | |
| 1293 | 1437 | * POST /mcp/attach — the OAuth-attach callback. The hub presents the |
| 1294 | 1438 | * signed nonce the plugin issued; on success we return this site's URL + |
| 1295 | 1439 | * token so the hub can record it. Nonce is the auth (admin-minted, |
| 1296 | 1440 | * HMAC-signed, time-bound), so no pre-shared token is required. |
| @@ -1298,10 +1442,13 @@ | ||
| 1298 | 1442 | * @param \WP_REST_Request $request Carries the nonce. |
| 1299 | 1443 | * @return \WP_REST_Response|\WP_Error |
| 1300 | 1444 | */ |
| 1301 | 1445 | public function rest_hub_attach_callback( \WP_REST_Request $request ) { |
| 1302 | - $nonce = (string) $request->get_param( 'nonce' ); | |
| 1303 | - $result = Mcp_Hub::verify_attach_nonce( $nonce ); | |
| 1446 | + $result = self::attach_result( | |
| 1447 | + (string) $request->get_param( 'nonce' ), | |
| 1448 | + (string) $request->get_param( 'code' ), | |
| 1449 | + (string) $request->get_param( 'code_verifier' ) | |
| 1450 | + ); | |
| 1304 | 1451 | if ( null === $result ) { |
| 1305 | 1452 | return new \WP_Error( |
| 1306 | 1453 | 'xspeed_attach_invalid', |
| 1307 | 1454 | __( 'Invalid or expired attach request.', 'xspeed' ), |
| @@ -1475,8 +1622,61 @@ | ||
| 1475 | 1622 | return $response; |
| 1476 | 1623 | } |
| 1477 | 1624 | |
| 1478 | 1625 | /** |
| 1626 | + * Reject an over-sized MCP request body before WordPress decodes it. | |
| 1627 | + * | |
| 1628 | + * `rest_pre_dispatch` is the last hook that runs before | |
| 1629 | + * `WP_REST_Server::dispatch()` calls `has_valid_params()`, and that is | |
| 1630 | + * what `json_decode()`s the body — before the permission callback, so an | |
| 1631 | + * unauthenticated caller already pays for the decode. Capping here is the | |
| 1632 | + * difference between reading a length and parsing megabytes of JSON. | |
| 1633 | + * | |
| 1634 | + * Refusing is not enough on its own. Core reads request params again on | |
| 1635 | + * the way out — `rest_filter_response_fields()` on `rest_post_dispatch` | |
| 1636 | + * looks up `_fields` — and for an `application/json` request that lookup | |
| 1637 | + * runs `parse_json_params()` over whatever body is still attached. So a | |
| 1638 | + * refused body is also emptied, and the 413 goes out with nothing left to | |
| 1639 | + * decode. QA measured a refused 3 MB body peaking near 90 MB without this. | |
| 1640 | + * | |
| 1641 | + * Scoped to the two routes that carry tool payloads, compared | |
| 1642 | + * case-insensitively: `WP_REST_Server::match_request_to_handler()` matches | |
| 1643 | + * routes with the `i` flag, so `/XSPEED/v1/MCP` reaches the same handler | |
| 1644 | + * and has to meet the same cap. Returning null leaves the request alone, | |
| 1645 | + * which is what this filter does for everything else. | |
| 1646 | + * | |
| 1647 | + * @param mixed $result A short-circuit response, if one is set. | |
| 1648 | + * @param mixed $server Unused; the REST server instance. | |
| 1649 | + * @param \WP_REST_Request $request Incoming request. | |
| 1650 | + * @return mixed Null to continue, or a WP_Error to refuse. | |
| 1651 | + */ | |
| 1652 | + public function cap_request_body( $result, $server = null, $request = null ) { | |
| 1653 | + if ( null !== $result || ! $request instanceof \WP_REST_Request ) { | |
| 1654 | + return $result; | |
| 1655 | + } | |
| 1656 | + | |
| 1657 | + $route = strtolower( (string) $request->get_route() ); | |
| 1658 | + $mcp = '/' . self::NS . '/mcp'; | |
| 1659 | + if ( $route !== $mcp && 0 !== strpos( $route, $mcp . '/tool/' ) ) { | |
| 1660 | + return $result; | |
| 1661 | + } | |
| 1662 | + | |
| 1663 | + if ( strlen( (string) $request->get_body() ) <= Mcp_Tools::MAX_TOOL_BODY_BYTES ) { | |
| 1664 | + return $result; | |
| 1665 | + } | |
| 1666 | + | |
| 1667 | + // Drop the body before refusing. Nothing downstream needs it, and | |
| 1668 | + // core's response pipeline would otherwise json_decode() it anyway. | |
| 1669 | + $request->set_body( '' ); | |
| 1670 | + | |
| 1671 | + return new \WP_Error( | |
| 1672 | + 'xspeed_mcp_tool_payload_too_large', | |
| 1673 | + __( 'The MCP tool payload is too large.', 'xspeed' ), | |
| 1674 | + array( 'status' => 413 ) | |
| 1675 | + ); | |
| 1676 | + } | |
| 1677 | + | |
| 1678 | + /** | |
| 1479 | 1679 | * Token-authenticated tool route for the hosted broker. Maps a broker |
| 1480 | 1680 | * tool call (e.g. GET /mcp/tool/get_cache_status) onto the shared |
| 1481 | 1681 | * Mcp_Tools catalog, so the broker path and the JSON-RPC path never |
| 1482 | 1682 | * drift. GET params + JSON body both feed the tool's arguments. |
| @@ -1482,8 +1682,18 @@ | ||
| 1482 | 1682 | * drift. GET params + JSON body both feed the tool's arguments. |
| 1483 | 1683 | */ |
| 1484 | 1684 | public function rest_tool( \WP_REST_Request $request ) { |
| 1485 | 1685 | $tool = (string) $request->get_param( 'tool' ); |
| 1686 | + // Second line behind cap_request_body(). This one still matters: the | |
| 1687 | + // pretty front-door path builds its own WP_REST_Request and calls the | |
| 1688 | + // handler without going through WP_REST_Server::dispatch() at all. | |
| 1689 | + if ( strlen( $request->get_body() ) > Mcp_Tools::MAX_TOOL_BODY_BYTES ) { | |
| 1690 | + return new \WP_Error( | |
| 1691 | + 'xspeed_mcp_tool_payload_too_large', | |
| 1692 | + __( 'The MCP tool payload is too large.', 'xspeed' ), | |
| 1693 | + array( 'status' => 413 ) | |
| 1694 | + ); | |
| 1695 | + } | |
| 1486 | 1696 | $args = $request->get_json_params(); |
| 1487 | 1697 | if ( ! is_array( $args ) ) { |
| 1488 | 1698 | $args = array(); |
| 1489 | 1699 | } |
| @@ -1575,18 +1785,9 @@ | ||
| 1575 | 1785 | header( 'Content-Type: text/html; charset=utf-8' ); |
| 1576 | 1786 | header( 'Cache-Control: no-store' ); |
| 1577 | 1787 | |
| 1578 | 1788 | echo '<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>' . esc_html__( 'Authorize AI access', 'xspeed' ) . '</title>'; |
| 1579 | - echo '<style>' | |
| 1580 | - . 'body{font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0f172a;color:#e2e8f0;margin:0;display:flex;min-height:100vh;align-items:center;justify-content:center}' | |
| 1581 | - . '.card{background:#1e293b;border:1px solid #334155;border-radius:16px;max-width:440px;padding:32px;box-shadow:0 10px 40px rgba(0,0,0,.4)}' | |
| 1582 | - . 'h1{font-size:20px;margin:0 0 4px}.sub{color:#94a3b8;font-size:13px;margin:0 0 24px}' | |
| 1583 | - . '.row{display:flex;justify-content:space-between;padding:10px 0;border-bottom:1px solid #334155;font-size:13px}' | |
| 1584 | - . '.row span:first-child{color:#94a3b8}.row span:last-child{font-weight:600;text-align:right;max-width:60%;word-break:break-word}' | |
| 1585 | - . '.actions{display:flex;gap:12px;margin-top:24px}' | |
| 1586 | - . 'button{flex:1;padding:12px;border-radius:10px;border:0;font-size:14px;font-weight:600;cursor:pointer}' | |
| 1587 | - . '.approve{background:#f5cd47;color:#1b2533}.deny{background:transparent;color:#94a3b8;border:1px solid #334155}' | |
| 1588 | - . '</style></head><body><div class="card">'; | |
| 1789 | + echo '<style>' . self::CONSENT_CSS . '</style></head><body><div class="card">'; // phpcs:ignore WordPress.Security.EscapeOutput -- static stylesheet constant. | |
| 1589 | 1790 | echo '<h1>' . esc_html__( 'Connect to xSpeed', 'xspeed' ) . '</h1>'; |
| 1590 | 1791 | /* translators: %s: AI client name. */ |
| 1591 | 1792 | echo '<p class="sub">' . esc_html( sprintf( __( '%s wants to manage the cache on this site.', 'xspeed' ), $client ) ) . '</p>'; |
| 1592 | 1793 | echo '<div class="row"><span>' . esc_html__( 'Site', 'xspeed' ) . '</span><span>' . esc_html( wp_parse_url( home_url(), PHP_URL_HOST ) ) . '</span></div>'; |