| 1 |
<?php |
| 2 |
/** |
| 3 |
* MCP module — the site side of xSpeed's MCP integration. |
| 4 |
* |
| 5 |
* PRIMARY path (no hosted infra): the plugin speaks the MCP protocol |
| 6 |
* DIRECTLY at this site's own URL. The user pastes their own site's MCP |
| 7 |
* endpoint + connection token into their AI client: |
| 8 |
* |
| 9 |
* https://thissite.com/xspeed/mcp (pretty, via rewrite) |
| 10 |
* https://thissite.com/wp-json/xspeed/v1/mcp (always-on fallback) |
| 11 |
* |
| 12 |
* The MCP JSON-RPC handling lives in Mcp_Server; the tool catalog in |
| 13 |
* Mcp_Tools. Auth is the per-site connection token (Mcp_Auth / |
| 14 |
* Mcp_Pairing). See IMPLEMENTATION.md §17. |
| 15 |
* |
| 16 |
* OPTIONAL path (hosted broker, api.xspeedcache.com): the same tool |
| 17 |
* catalog is also exposed as token-authenticated REST routes under |
| 18 |
* /xspeed/v1/mcp/tool/* so a hosted broker can proxy to it for a single |
| 19 |
* shared vanity URL. Not required for the product to work. |
| 20 |
* |
| 21 |
* Admin-only management routes (manage_options) drive the dashboard |
| 22 |
* "Connect AI" panel: /mcp/connection, /mcp/connect, /mcp/disconnect. |
| 23 |
* |
| 24 |
* These routes register DIRECTLY on rest_api_init (NOT via Rest_Manager, |
| 25 |
* whose wrap_permission() forces a current_user_can() gate that MCP's |
| 26 |
* token-only calls can never satisfy). |
| 27 |
* |
| 28 |
* Tier: Free. The ONLY gate is possession of the per-site connection |
| 29 |
* token, which an admin (manage_options) must explicitly mint via |
| 30 |
* Connect. A fresh install ships with no token → every MCP call is 401 |
| 31 |
* until the admin opts in. Adds ZERO cache logic. |
| 32 |
* |
| 33 |
* @package XSpeed |
| 34 |
*/ |
| 35 |
|
| 36 |
declare(strict_types=1); |
| 37 |
|
| 38 |
namespace XSpeed\Modules\Mcp; |
| 39 |
|
| 40 |
use XSpeed\Module; |
| 41 |
use XSpeed\Onboarding; |
| 42 |
|
| 43 |
defined( 'ABSPATH' ) || exit; |
| 44 |
|
| 45 |
final class McpModule extends Module { |
| 46 |
|
| 47 |
public const SLUG = 'mcp'; |
| 48 |
public const TIER = self::TIER_FREE; |
| 49 |
public const VERSION = '1.0.0'; |
| 50 |
|
| 51 |
/** |
| 52 |
* Every rewrite rule this module registers, in registration order. |
| 53 |
* |
| 54 |
* Single source of truth: add_rewrite() registers these, and the self-heal |
| 55 |
* guard re-flushes when any is missing from the stored table. They were two |
| 56 |
* hand-maintained lists before, which is a silent drift risk — a rule |
| 57 |
* dropped from one and not the other leaves the guard restoring a rule |
| 58 |
* nothing registers, or never firing for one that is registered. |
| 59 |
* |
| 60 |
* @var string[] Rewrite regexes. The query each maps to is built in |
| 61 |
* add_rewrite(), which also fixes their order. |
| 62 |
*/ |
| 63 |
public const REWRITE_RULES = array( |
| 64 |
'^xspeed/mcp/([a-f0-9]{64})/?$', |
| 65 |
'^xspeed/mcp/?$', |
| 66 |
'^xspeed/mcp/attach/?$', |
| 67 |
// OAuth discovery, root form. RFC 9728 §3.1 / RFC 8414 §3.1 put the |
| 68 |
// `.well-known` segment BEFORE the resource path. |
| 69 |
'^\.well-known/oauth-(protected-resource|authorization-server)/?$', |
| 70 |
// OAuth discovery, path-suffixed form. Real clients (Claude Desktop |
| 71 |
// among them) request THIS one; serving only the root form 404s them. |
| 72 |
// It names our own resource path explicitly: a catch-all tail here |
| 73 |
// also matched other MCP plugins' discovery URLs on the same site and |
| 74 |
// answered them with our metadata, which broke their connectors. |
| 75 |
'^\.well-known/oauth-(protected-resource|authorization-server)/xspeed/mcp/?$', |
| 76 |
'^xspeed/authorize/?$', |
| 77 |
); |
| 78 |
|
| 79 |
/** REST namespace shared with Free. */ |
| 80 |
private const NS = 'xspeed/v1'; |
| 81 |
|
| 82 |
/** Query var flagging a pretty /xspeed/mcp request. */ |
| 83 |
private const QUERY_VAR = 'xspeed_mcp'; |
| 84 |
|
| 85 |
/** Query var carrying the token when embedded in the URL path. */ |
| 86 |
private const TOKEN_QUERY_VAR = 'xspeed_mcp_token'; |
| 87 |
|
| 88 |
/** Query var flagging a /.well-known/ OAuth discovery request. */ |
| 89 |
private const WELLKNOWN_QUERY_VAR = 'xspeed_mcp_wellknown'; |
| 90 |
|
| 91 |
/** |
| 92 |
* Query var flagging the browser-facing OAuth authorize page. This is |
| 93 |
* served OUTSIDE the REST API on purpose: a REST route only honors cookie |
| 94 |
* auth when a REST nonce accompanies it, but a browser arriving from |
| 95 |
* wp-login carries the cookie with NO nonce — so is_user_logged_in() would |
| 96 |
* be false there and the consent screen would loop back to login forever. |
| 97 |
* A normal front-end URL (rewrite + parse_request) sees standard cookie |
| 98 |
* auth, so the logged-in admin check works. |
| 99 |
*/ |
| 100 |
private const AUTHORIZE_QUERY_VAR = 'xspeed_mcp_authorize'; |
| 101 |
|
| 102 |
/** Front-end path of the browser-facing authorize page. */ |
| 103 |
private const AUTHORIZE_PATH = 'xspeed/authorize'; |
| 104 |
|
| 105 |
/** Query var flagging the pretty /xspeed/mcp/attach callback. */ |
| 106 |
private const ATTACH_QUERY_VAR = 'xspeed_mcp_attach'; |
| 107 |
|
| 108 |
public function ui_metadata(): array { |
| 109 |
return array( |
| 110 |
'label' => 'MCP Server', |
| 111 |
'icon' => 'Sparkles', |
| 112 |
'description' => 'Control this site\'s cache from Claude and other AI agents.', |
| 113 |
'custom_panel' => 'McpPanel', |
| 114 |
); |
| 115 |
} |
| 116 |
|
| 117 |
/** |
| 118 |
* MCP pairing state lives in xspeed_module_mcp but is managed by |
| 119 |
* Mcp_Pairing, not the schema engine. Empty schema so the base class |
| 120 |
* doesn't auto-register generic settings routes. |
| 121 |
*/ |
| 122 |
public function settings_schema(): array { |
| 123 |
return array(); |
| 124 |
} |
| 125 |
|
| 126 |
/** |
| 127 |
* All MCP routes register directly (see class docblock). Returning an |
| 128 |
* empty array keeps Rest_Manager out of the token-auth path entirely. |
| 129 |
*/ |
| 130 |
public function rest_routes(): array { |
| 131 |
return array(); |
| 132 |
} |
| 133 |
|
| 134 |
public function boot(): void { |
| 135 |
add_action( 'rest_api_init', array( $this, 'register_rest' ) ); |
| 136 |
|
| 137 |
// Pretty per-site endpoint: /xspeed/mcp → MCP JSON-RPC handler. |
| 138 |
add_action( 'init', array( $this, 'add_rewrite' ) ); |
| 139 |
add_filter( 'query_vars', array( $this, 'register_query_var' ) ); |
| 140 |
add_action( 'parse_request', array( $this, 'maybe_handle_pretty_endpoint' ) ); |
| 141 |
|
| 142 |
// Hub redirect-return: after the user approves on the Hub, it sends the |
| 143 |
// browser back to a plugin admin URL carrying ?xspeed_connected=1 plus |
| 144 |
// the account email + the SAME signed nonce we minted. We verify our own |
| 145 |
// nonce and mark this admin attached — no server-to-server callback |
| 146 |
// needed, so it works for local/firewalled sites too. |
| 147 |
add_action( 'admin_init', array( $this, 'maybe_handle_hub_return' ) ); |
| 148 |
} |
| 149 |
|
| 150 |
/** |
| 151 |
* Handle the browser landing back from the Hub after a connect. Idempotent |
| 152 |
* and safe to run on every admin page load: it only acts when the return |
| 153 |
* markers are present and the nonce verifies. |
| 154 |
*/ |
| 155 |
public function maybe_handle_hub_return(): void { |
| 156 |
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- auth is the signed HMAC nonce below, not a WP nonce; this is a read-only routing check. |
| 157 |
$nonce = isset( $_GET['xspeed_hub_nonce'] ) ? sanitize_text_field( wp_unslash( $_GET['xspeed_hub_nonce'] ) ) : ''; |
| 158 |
$email = isset( $_GET['xspeed_hub_email'] ) ? sanitize_email( wp_unslash( $_GET['xspeed_hub_email'] ) ) : ''; |
| 159 |
|
| 160 |
/* |
| 161 |
* Trigger on the signed nonce, not on `xspeed_connected`. |
| 162 |
* |
| 163 |
* The Hub bounces the browser back with xspeed_hub_nonce + |
| 164 |
* xspeed_hub_email, but it does NOT always append xspeed_connected — |
| 165 |
* that marker only survives when the return_url we handed it carried |
| 166 |
* one. Gating on it meant a real, correctly-signed return was ignored: |
| 167 |
* the attach was never recorded, the params were never stripped, and |
| 168 |
* the card kept showing "Not connected" while the nonce sat in the |
| 169 |
* address bar. The nonce is the actual proof of a genuine round trip, |
| 170 |
* so it is what this handler keys on. (FBS-84086) |
| 171 |
*/ |
| 172 |
if ( '' === $nonce && empty( $_GET['xspeed_connected'] ) ) { |
| 173 |
return; |
| 174 |
} |
| 175 |
// phpcs:enable WordPress.Security.NonceVerification.Recommended |
| 176 |
|
| 177 |
if ( ! current_user_can( 'manage_options' ) ) { |
| 178 |
return; |
| 179 |
} |
| 180 |
|
| 181 |
// Verify OUR own signed nonce (proves the round-trip went through the |
| 182 |
// Hub with a token we minted), then record the connection. |
| 183 |
if ( '' !== $nonce ) { |
| 184 |
$verified = Mcp_Hub::verify_attach_nonce( $nonce ); |
| 185 |
if ( null !== $verified ) { |
| 186 |
$uid = isset( $verified['user_id'] ) ? (int) $verified['user_id'] : get_current_user_id(); |
| 187 |
Mcp_Hub::mark_attached( $email, $uid ?: null ); |
| 188 |
} |
| 189 |
} |
| 190 |
|
| 191 |
// ALWAYS strip the one-time return markers from the URL and redirect to |
| 192 |
// the clean address. These params are single-use; if they persist in the |
| 193 |
// browser URL, a later reload re-triggers the "just connected" path and |
| 194 |
// flashes a stale connected state even after the user has disconnected. |
| 195 |
$clean = remove_query_arg( array( 'xspeed_connected', 'xspeed_hub_nonce', 'xspeed_hub_email' ) ); |
| 196 |
|
| 197 |
// The setup wizard keeps its current step in component state, so a |
| 198 |
// redirect remounts it at step 1 — dumping the user back at the START of |
| 199 |
// onboarding immediately after they finished its LAST step. Carry a |
| 200 |
// durable hint so the wizard resumes on Connect instead. It's a plain |
| 201 |
// step marker, not an auth signal (the nonce above did that job), and |
| 202 |
// it's safe to leave in the URL: re-loading it just re-opens the same |
| 203 |
// step rather than re-running the connect path. (PM feedback) |
| 204 |
if ( false !== strpos( (string) $clean, 'page=' . Onboarding::PAGE_SLUG ) ) { |
| 205 |
$clean = add_query_arg( 'xspeed_step', 'connect', $clean ); |
| 206 |
} |
| 207 |
|
| 208 |
wp_safe_redirect( $clean ); |
| 209 |
exit; |
| 210 |
} |
| 211 |
|
| 212 |
/** |
| 213 |
* Flush rewrites once when the module first boots so /xspeed/mcp works |
| 214 |
* without a manual permalink re-save. Cheap: gated on a one-shot flag. |
| 215 |
*/ |
| 216 |
public function activate(): void { |
| 217 |
$this->add_rewrite(); |
| 218 |
flush_rewrite_rules( false ); |
| 219 |
} |
| 220 |
|
| 221 |
public function deactivate(): void { |
| 222 |
flush_rewrite_rules( false ); |
| 223 |
} |
| 224 |
|
| 225 |
// -- Pretty endpoint: /xspeed/mcp -- |
| 226 |
|
| 227 |
public function add_rewrite(): void { |
| 228 |
// Token-in-URL form: /xspeed/mcp/<token> — a single string the user |
| 229 |
// pastes into their AI client (no separate token field). The bare |
| 230 |
// /xspeed/mcp still works with a Bearer/header token. |
| 231 |
add_rewrite_rule( |
| 232 |
'^xspeed/mcp/([a-f0-9]{64})/?$', |
| 233 |
'index.php?' . self::QUERY_VAR . '=1&' . self::TOKEN_QUERY_VAR . '=$matches[1]', |
| 234 |
'top' |
| 235 |
); |
| 236 |
add_rewrite_rule( '^xspeed/mcp/?$', 'index.php?' . self::QUERY_VAR . '=1', 'top' ); |
| 237 |
|
| 238 |
// Pretty attach-callback endpoint: /xspeed/mcp/attach — the hub POSTs |
| 239 |
// the signed nonce here to verify + fetch the token. Uses the plugin's |
| 240 |
// own rewrite (consistent with the MCP URL, survives hosts that block |
| 241 |
// /wp-json). Placed BEFORE the token rule would never match "attach" |
| 242 |
// (that rule requires 64 hex chars), so ordering is safe. |
| 243 |
add_rewrite_rule( '^xspeed/mcp/attach/?$', 'index.php?' . self::ATTACH_QUERY_VAR . '=1', 'top' ); |
| 244 |
|
| 245 |
// OAuth discovery documents. RFC 9728 §3.1 / RFC 8414 §3.1 place the |
| 246 |
// `.well-known` segment BEFORE the resource path, so our resource at |
| 247 |
// /xspeed/mcp is discovered at BOTH: |
| 248 |
// /.well-known/oauth-protected-resource (root form) |
| 249 |
// /.well-known/oauth-protected-resource/xspeed/mcp (path-suffixed) |
| 250 |
// Real clients (Claude Desktop among them) request the path-suffixed |
| 251 |
// form; serving only the root form 404s them and the connection aborts. |
| 252 |
// |
| 253 |
// Both are matched EXACTLY. A `(?:/.*)?` tail covers the same two URLs |
| 254 |
// in one rule, but also matches every OTHER plugin's discovery URL on |
| 255 |
// the same site — and WordPress matches rewrite rules in table order |
| 256 |
// rather than by specificity, so a sibling's own exact rule never gets |
| 257 |
// reached. Its clients then receive OUR metadata, find a resource and |
| 258 |
// issuer that do not match what they are connecting to, and abort |
| 259 |
// before the login screen. |
| 260 |
add_rewrite_rule( |
| 261 |
'^\\.well-known/oauth-(protected-resource|authorization-server)/?$', |
| 262 |
'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]', |
| 263 |
'top' |
| 264 |
); |
| 265 |
add_rewrite_rule( |
| 266 |
'^\\.well-known/oauth-(protected-resource|authorization-server)/xspeed/mcp/?$', |
| 267 |
'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]', |
| 268 |
'top' |
| 269 |
); |
| 270 |
|
| 271 |
// Browser-facing OAuth consent page — served OUTSIDE REST so cookie |
| 272 |
// auth (is_user_logged_in) works after the wp-login round-trip. |
| 273 |
add_rewrite_rule( '^xspeed/authorize/?$', 'index.php?' . self::AUTHORIZE_QUERY_VAR . '=1', 'top' ); |
| 274 |
|
| 275 |
// Self-heal: flush once if ANY of our rules is missing from the stored |
| 276 |
// rewrite table. Checking only the first rule is not enough — a site |
| 277 |
// flushed under an older build (which had /xspeed/mcp but not the |
| 278 |
// later /xspeed/authorize + /.well-known rules) keeps that first rule, |
| 279 |
// so the guard never fires and OAuth discovery 404s forever. Guard on |
| 280 |
// the full set so any newly-added rule triggers a re-flush. |
| 281 |
$rules = get_option( 'rewrite_rules' ); |
| 282 |
if ( is_array( $rules ) ) { |
| 283 |
foreach ( self::REWRITE_RULES as $rule ) { |
| 284 |
if ( ! isset( $rules[ $rule ] ) ) { |
| 285 |
flush_rewrite_rules( false ); |
| 286 |
break; |
| 287 |
} |
| 288 |
} |
| 289 |
} |
| 290 |
} |
| 291 |
|
| 292 |
/** |
| 293 |
* @param string[] $vars Registered query vars. |
| 294 |
* @return string[] |
| 295 |
*/ |
| 296 |
public function register_query_var( array $vars ): array { |
| 297 |
$vars[] = self::QUERY_VAR; |
| 298 |
$vars[] = self::TOKEN_QUERY_VAR; |
| 299 |
$vars[] = self::WELLKNOWN_QUERY_VAR; |
| 300 |
$vars[] = self::AUTHORIZE_QUERY_VAR; |
| 301 |
$vars[] = self::ATTACH_QUERY_VAR; |
| 302 |
return $vars; |
| 303 |
} |
| 304 |
|
| 305 |
/** |
| 306 |
* Serve the MCP endpoint on the pretty path. Runs on parse_request so |
| 307 |
* it fires before the main query, and short-circuits WP entirely. |
| 308 |
* |
| 309 |
* @param \WP $wp The WP request object. |
| 310 |
*/ |
| 311 |
public function maybe_handle_pretty_endpoint( $wp ): void { |
| 312 |
// OAuth discovery documents (served at the site root). |
| 313 |
if ( ! empty( $wp->query_vars[ self::WELLKNOWN_QUERY_VAR ] ) ) { |
| 314 |
$doc = (string) $wp->query_vars[ self::WELLKNOWN_QUERY_VAR ]; |
| 315 |
$data = 'authorization-server' === $doc |
| 316 |
? Mcp_OAuth::authorization_server_metadata() |
| 317 |
: Mcp_OAuth::protected_resource_metadata(); |
| 318 |
status_header( 200 ); |
| 319 |
header( 'Content-Type: application/json; charset=utf-8' ); |
| 320 |
// Discovery metadata is public + cacheable. |
| 321 |
header( 'Cache-Control: public, max-age=3600' ); |
| 322 |
echo wp_json_encode( $data ); |
| 323 |
exit; |
| 324 |
} |
| 325 |
|
| 326 |
// Pretty attach-callback: /xspeed/mcp/attach. The hub POSTs the signed |
| 327 |
// nonce; we verify it and return this site's URL + token. Auth is the |
| 328 |
// nonce itself (admin-minted, HMAC-signed), so no credential needed. |
| 329 |
if ( ! empty( $wp->query_vars[ self::ATTACH_QUERY_VAR ] ) ) { |
| 330 |
$body = json_decode( (string) file_get_contents( 'php://input' ), true ); |
| 331 |
$nonce = is_array( $body ) && isset( $body['nonce'] ) ? (string) $body['nonce'] : ''; |
| 332 |
$result = Mcp_Hub::verify_attach_nonce( $nonce ); |
| 333 |
header( 'Content-Type: application/json; charset=utf-8' ); |
| 334 |
header( 'Cache-Control: no-store' ); |
| 335 |
if ( null === $result ) { |
| 336 |
status_header( 403 ); |
| 337 |
echo wp_json_encode( array( 'error' => 'invalid_or_expired_attach_request' ) ); |
| 338 |
} else { |
| 339 |
status_header( 200 ); |
| 340 |
echo wp_json_encode( $result ); |
| 341 |
} |
| 342 |
exit; |
| 343 |
} |
| 344 |
|
| 345 |
// Browser-facing OAuth consent page (cookie auth applies here). |
| 346 |
if ( ! empty( $wp->query_vars[ self::AUTHORIZE_QUERY_VAR ] ) ) { |
| 347 |
$this->handle_authorize_page(); |
| 348 |
return; |
| 349 |
} |
| 350 |
|
| 351 |
if ( empty( $wp->query_vars[ self::QUERY_VAR ] ) ) { |
| 352 |
return; |
| 353 |
} |
| 354 |
|
| 355 |
$request = new \WP_REST_Request( 'POST', '/xspeed/v1/mcp' ); |
| 356 |
$request->set_header( 'content-type', 'application/json' ); |
| 357 |
// Carry the auth headers + raw body from the live PHP request. |
| 358 |
foreach ( array( 'authorization', Mcp_Auth::TOKEN_HEADER ) as $h ) { |
| 359 |
$val = self::server_header( $h ); |
| 360 |
if ( null !== $val ) { |
| 361 |
$request->set_header( $h, $val ); |
| 362 |
} |
| 363 |
} |
| 364 |
// Token embedded in the URL path (/xspeed/mcp/<token>) — surface it |
| 365 |
// as the standard token header so Mcp_Server validates it the same |
| 366 |
// way. A header/Bearer token (if also sent) still takes precedence. |
| 367 |
$path_token = isset( $wp->query_vars[ self::TOKEN_QUERY_VAR ] ) |
| 368 |
? (string) $wp->query_vars[ self::TOKEN_QUERY_VAR ] |
| 369 |
: ''; |
| 370 |
if ( '' !== $path_token && '' === (string) $request->get_header( Mcp_Auth::TOKEN_HEADER ) && '' === (string) $request->get_header( 'authorization' ) ) { |
| 371 |
$request->set_header( Mcp_Auth::TOKEN_HEADER, $path_token ); |
| 372 |
} |
| 373 |
$request->set_body( file_get_contents( 'php://input' ) ); |
| 374 |
|
| 375 |
$response = Mcp_Server::handle( $request ); |
| 376 |
$this->emit_json( $response ); |
| 377 |
} |
| 378 |
|
| 379 |
// -- REST registration -- |
| 380 |
|
| 381 |
public function register_rest(): void { |
| 382 |
// --- MCP JSON-RPC endpoint (fallback path via wp-json) ----------- |
| 383 |
// permission_callback is __return_true because Mcp_Server does its |
| 384 |
// own token auth and must reply with a JSON-RPC 401, not a bare WP |
| 385 |
// permission failure. |
| 386 |
register_rest_route( |
| 387 |
self::NS, |
| 388 |
'/mcp', |
| 389 |
array( |
| 390 |
'methods' => 'POST', |
| 391 |
'callback' => array( $this, 'rest_mcp' ), |
| 392 |
'permission_callback' => '__return_true', |
| 393 |
) |
| 394 |
); |
| 395 |
|
| 396 |
// --- Admin-only management routes (dashboard) -------------------- |
| 397 |
register_rest_route( |
| 398 |
self::NS, |
| 399 |
'/mcp/connection', |
| 400 |
array( |
| 401 |
'methods' => 'GET', |
| 402 |
'callback' => array( $this, 'rest_connection' ), |
| 403 |
'permission_callback' => array( $this, 'admin_permission' ), |
| 404 |
) |
| 405 |
); |
| 406 |
register_rest_route( |
| 407 |
self::NS, |
| 408 |
'/mcp/activity', |
| 409 |
array( |
| 410 |
'methods' => 'GET', |
| 411 |
'callback' => array( $this, 'rest_activity' ), |
| 412 |
'permission_callback' => array( $this, 'admin_permission' ), |
| 413 |
'args' => array( |
| 414 |
'limit' => array( |
| 415 |
'type' => 'integer', |
| 416 |
'required' => false, |
| 417 |
'default' => 50, |
| 418 |
'description' => 'Maximum entries to return (newest first).', |
| 419 |
), |
| 420 |
), |
| 421 |
) |
| 422 |
); |
| 423 |
register_rest_route( |
| 424 |
self::NS, |
| 425 |
'/mcp/activity/clear', |
| 426 |
array( |
| 427 |
'methods' => 'POST', |
| 428 |
'callback' => array( $this, 'rest_activity_clear' ), |
| 429 |
'permission_callback' => array( $this, 'admin_permission' ), |
| 430 |
) |
| 431 |
); |
| 432 |
register_rest_route( |
| 433 |
self::NS, |
| 434 |
'/mcp/connect', |
| 435 |
array( |
| 436 |
'methods' => 'POST', |
| 437 |
'callback' => array( $this, 'rest_connect' ), |
| 438 |
'permission_callback' => array( $this, 'admin_permission' ), |
| 439 |
'args' => array( |
| 440 |
'read_only' => array( |
| 441 |
'type' => 'boolean', |
| 442 |
'required' => false, |
| 443 |
'default' => false, |
| 444 |
'description' => 'Grant read-only access (no purge/toggle/settings changes).', |
| 445 |
), |
| 446 |
), |
| 447 |
) |
| 448 |
); |
| 449 |
register_rest_route( |
| 450 |
self::NS, |
| 451 |
'/mcp/rotate', |
| 452 |
array( |
| 453 |
'methods' => 'POST', |
| 454 |
'callback' => array( $this, 'rest_rotate' ), |
| 455 |
'permission_callback' => array( $this, 'admin_permission' ), |
| 456 |
'args' => array( |
| 457 |
'read_only' => array( |
| 458 |
'type' => 'boolean', |
| 459 |
'required' => false, |
| 460 |
'description' => 'Optionally set read-only on the new token; omit to keep current scopes.', |
| 461 |
), |
| 462 |
), |
| 463 |
) |
| 464 |
); |
| 465 |
register_rest_route( |
| 466 |
self::NS, |
| 467 |
'/mcp/access', |
| 468 |
array( |
| 469 |
'methods' => 'POST', |
| 470 |
'callback' => array( $this, 'rest_access' ), |
| 471 |
'permission_callback' => array( $this, 'admin_permission' ), |
| 472 |
'args' => array( |
| 473 |
'read_only' => array( |
| 474 |
'type' => 'boolean', |
| 475 |
'required' => true, |
| 476 |
'description' => 'Switch the live connection to read-only (true) or read & write (false), keeping the same token.', |
| 477 |
), |
| 478 |
), |
| 479 |
) |
| 480 |
); |
| 481 |
register_rest_route( |
| 482 |
self::NS, |
| 483 |
'/mcp/disconnect', |
| 484 |
array( |
| 485 |
'methods' => 'POST', |
| 486 |
'callback' => array( $this, 'rest_disconnect' ), |
| 487 |
'permission_callback' => array( $this, 'admin_permission' ), |
| 488 |
) |
| 489 |
); |
| 490 |
|
| 491 |
// --- xSpeed Hub (multi-site) attach routes ------------------------ |
| 492 |
register_rest_route( |
| 493 |
self::NS, |
| 494 |
'/mcp/hub', |
| 495 |
array( |
| 496 |
'methods' => 'GET', |
| 497 |
'callback' => array( $this, 'rest_hub_status' ), |
| 498 |
'permission_callback' => array( $this, 'admin_permission' ), |
| 499 |
) |
| 500 |
); |
| 501 |
register_rest_route( |
| 502 |
self::NS, |
| 503 |
'/mcp/hub/token', |
| 504 |
array( |
| 505 |
'methods' => 'POST', |
| 506 |
'callback' => array( $this, 'rest_hub_token' ), |
| 507 |
'permission_callback' => array( $this, 'admin_permission' ), |
| 508 |
) |
| 509 |
); |
| 510 |
register_rest_route( |
| 511 |
self::NS, |
| 512 |
'/mcp/hub/attached', |
| 513 |
array( |
| 514 |
'methods' => 'POST', |
| 515 |
'callback' => array( $this, 'rest_hub_attached' ), |
| 516 |
'permission_callback' => array( $this, 'admin_permission' ), |
| 517 |
'args' => array( |
| 518 |
'account_email' => array( |
| 519 |
'type' => 'string', |
| 520 |
'required' => true, |
| 521 |
'description' => 'The hub account email this site was attached to.', |
| 522 |
), |
| 523 |
), |
| 524 |
) |
| 525 |
); |
| 526 |
register_rest_route( |
| 527 |
self::NS, |
| 528 |
'/mcp/hub/disconnect', |
| 529 |
array( |
| 530 |
'methods' => 'POST', |
| 531 |
'callback' => array( $this, 'rest_hub_disconnect' ), |
| 532 |
'permission_callback' => array( $this, 'admin_permission' ), |
| 533 |
) |
| 534 |
); |
| 535 |
// OAuth-attach callback: the hub calls this with the signed nonce the |
| 536 |
// plugin issued. Auth is the nonce itself (no pre-shared token), so |
| 537 |
// permission_callback is open — the handler validates the nonce. |
| 538 |
register_rest_route( |
| 539 |
self::NS, |
| 540 |
'/mcp/attach', |
| 541 |
array( |
| 542 |
'methods' => 'POST', |
| 543 |
'callback' => array( $this, 'rest_hub_attach_callback' ), |
| 544 |
'permission_callback' => '__return_true', |
| 545 |
'args' => array( |
| 546 |
'nonce' => array( |
| 547 |
'type' => 'string', |
| 548 |
'required' => true, |
| 549 |
'description' => 'The signed attach nonce the plugin issued.', |
| 550 |
), |
| 551 |
), |
| 552 |
) |
| 553 |
); |
| 554 |
|
| 555 |
// --- OAuth 2.1 authorization server (the "paste a URL only" path) - |
| 556 |
// Discovery, dynamic client registration, and the token endpoint are |
| 557 |
// all public (permission enforced inside): a client must reach them |
| 558 |
// BEFORE it holds any credential. The authorize endpoint gates on a |
| 559 |
// logged-in admin inside its handler (anonymous → wp-login redirect). |
| 560 |
register_rest_route( |
| 561 |
self::NS, |
| 562 |
'/mcp/oauth/register', |
| 563 |
array( |
| 564 |
'methods' => 'POST', |
| 565 |
'callback' => array( $this, 'rest_oauth_register' ), |
| 566 |
'permission_callback' => '__return_true', |
| 567 |
) |
| 568 |
); |
| 569 |
// NOTE: /authorize is deliberately NOT a REST route — it is served as a |
| 570 |
// normal front-end page at /xspeed/authorize (see handle_authorize_page) |
| 571 |
// so cookie auth works after the wp-login round-trip. |
| 572 |
register_rest_route( |
| 573 |
self::NS, |
| 574 |
'/mcp/oauth/token', |
| 575 |
array( |
| 576 |
'methods' => 'POST', |
| 577 |
'callback' => array( $this, 'rest_oauth_token' ), |
| 578 |
'permission_callback' => '__return_true', |
| 579 |
) |
| 580 |
); |
| 581 |
|
| 582 |
// --- MCP-token-only tool routes (optional hosted-broker path) ---- |
| 583 |
$tool_perm = array( Mcp_Auth::class, 'permission' ); |
| 584 |
register_rest_route( |
| 585 |
self::NS, |
| 586 |
// [a-z0-9_-]+ — the HYPHEN is the one that matters, not the digit. |
| 587 |
// Generated tool names carry their module slug verbatim, and 33 of |
| 588 |
// the 92 in the catalog have a hyphenated slug |
| 589 |
// (xspeed_cache-404_status, xspeed_migration-pro_apply, |
| 590 |
// xspeed_smart-predict_status …). Every one of those returned |
| 591 |
// rest_no_route through the broker path. The earlier widening to |
| 592 |
// [a-z0-9_]+ un-blocked nothing: the only digit-bearing name is |
| 593 |
// cache-404, whose problem was the hyphen. (QA on #158) */ |
| 594 |
'/mcp/tool/(?P<tool>[a-z0-9_-]+)', |
| 595 |
array( |
| 596 |
array( |
| 597 |
'methods' => 'GET', |
| 598 |
'callback' => array( $this, 'rest_tool' ), |
| 599 |
'permission_callback' => $tool_perm, |
| 600 |
), |
| 601 |
array( |
| 602 |
'methods' => 'POST', |
| 603 |
'callback' => array( $this, 'rest_tool' ), |
| 604 |
'permission_callback' => $tool_perm, |
| 605 |
), |
| 606 |
) |
| 607 |
); |
| 608 |
} |
| 609 |
|
| 610 |
/** |
| 611 |
* Capability gate for the admin-only management routes. |
| 612 |
* |
| 613 |
* @return bool |
| 614 |
*/ |
| 615 |
public function admin_permission(): bool { |
| 616 |
return current_user_can( 'manage_options' ); |
| 617 |
} |
| 618 |
|
| 619 |
// -- Handlers ---------------------------------------------------------- |
| 620 |
|
| 621 |
/** |
| 622 |
* MCP JSON-RPC over the wp-json fallback path. |
| 623 |
* |
| 624 |
* @param \WP_REST_Request $request Incoming request. |
| 625 |
* @return \WP_REST_Response |
| 626 |
*/ |
| 627 |
public function rest_mcp( \WP_REST_Request $request ) { |
| 628 |
$response = Mcp_Server::handle( $request ); |
| 629 |
// Advertise the MCP protocol version on the wp-json transport too, so |
| 630 |
// both endpoints behave identically to a strict Streamable-HTTP client. |
| 631 |
$response->header( 'MCP-Protocol-Version', Mcp_Server::PROTOCOL_VERSION ); |
| 632 |
return $response; |
| 633 |
} |
| 634 |
|
| 635 |
/** |
| 636 |
* GET /mcp/connection — pairing status for the dashboard. |
| 637 |
* |
| 638 |
* @param \WP_REST_Request $request Unused. |
| 639 |
* @return \WP_REST_Response |
| 640 |
*/ |
| 641 |
public function rest_connection( \WP_REST_Request $request ) { |
| 642 |
unset( $request ); |
| 643 |
return rest_ensure_response( Mcp_Pairing::public_status() ); |
| 644 |
} |
| 645 |
|
| 646 |
/** |
| 647 |
* GET /mcp/activity — the audit trail of AI tool calls. |
| 648 |
* |
| 649 |
* @param \WP_REST_Request $request Carries the optional limit. |
| 650 |
* @return \WP_REST_Response|\WP_Error |
| 651 |
*/ |
| 652 |
public function rest_activity( \WP_REST_Request $request ) { |
| 653 |
$limit = (int) $request->get_param( 'limit' ); |
| 654 |
|
| 655 |
return rest_ensure_response( |
| 656 |
array( |
| 657 |
'entries' => Mcp_Activity_Log::entries( $limit > 0 ? $limit : 50 ), |
| 658 |
'summary' => Mcp_Activity_Log::summary(), |
| 659 |
) |
| 660 |
); |
| 661 |
} |
| 662 |
|
| 663 |
/** |
| 664 |
* POST /mcp/activity/clear — wipe the audit trail. |
| 665 |
* |
| 666 |
* @param \WP_REST_Request $request Unused. |
| 667 |
* @return \WP_REST_Response|\WP_Error |
| 668 |
*/ |
| 669 |
public function rest_activity_clear( \WP_REST_Request $request ) { |
| 670 |
unset( $request ); |
| 671 |
$cleared = Mcp_Activity_Log::clear(); |
| 672 |
|
| 673 |
return rest_ensure_response( |
| 674 |
array( |
| 675 |
'cleared' => $cleared, |
| 676 |
'entries' => Mcp_Activity_Log::entries(), |
| 677 |
'summary' => Mcp_Activity_Log::summary(), |
| 678 |
) |
| 679 |
); |
| 680 |
} |
| 681 |
|
| 682 |
/** |
| 683 |
* POST /mcp/connect — mint a connection token. |
| 684 |
* |
| 685 |
* @param \WP_REST_Request $request Unused. |
| 686 |
* @return \WP_REST_Response|\WP_Error |
| 687 |
*/ |
| 688 |
public function rest_connect( \WP_REST_Request $request ) { |
| 689 |
$read_only = (bool) $request->get_param( 'read_only' ); |
| 690 |
$result = Mcp_Pairing::connect( $read_only ); |
| 691 |
if ( is_wp_error( $result ) ) { |
| 692 |
return $result; |
| 693 |
} |
| 694 |
return rest_ensure_response( $result ); |
| 695 |
} |
| 696 |
|
| 697 |
/** |
| 698 |
* POST /mcp/rotate — mint a fresh token, invalidating the old one. |
| 699 |
* |
| 700 |
* @param \WP_REST_Request $request Carries optional read_only. |
| 701 |
* @return \WP_REST_Response |
| 702 |
*/ |
| 703 |
public function rest_rotate( \WP_REST_Request $request ) { |
| 704 |
$read_only = null; |
| 705 |
if ( null !== $request->get_param( 'read_only' ) ) { |
| 706 |
$read_only = (bool) $request->get_param( 'read_only' ); |
| 707 |
} |
| 708 |
return rest_ensure_response( Mcp_Pairing::rotate( $read_only ) ); |
| 709 |
} |
| 710 |
|
| 711 |
/** |
| 712 |
* POST /mcp/access — change the live connection's read-only state WITHOUT |
| 713 |
* minting a new token (the paired client keeps working; only its allowed |
| 714 |
* tools change). This is what the dashboard's read-only toggle calls. |
| 715 |
* |
| 716 |
* @param \WP_REST_Request $request Carries the required read_only bool. |
| 717 |
* @return \WP_REST_Response|\WP_Error |
| 718 |
*/ |
| 719 |
public function rest_access( \WP_REST_Request $request ) { |
| 720 |
$read_only = (bool) $request->get_param( 'read_only' ); |
| 721 |
$result = Mcp_Pairing::set_read_only( $read_only ); |
| 722 |
if ( is_wp_error( $result ) ) { |
| 723 |
return $result; |
| 724 |
} |
| 725 |
return rest_ensure_response( $result ); |
| 726 |
} |
| 727 |
|
| 728 |
/** |
| 729 |
* POST /mcp/disconnect — revoke the connection token. |
| 730 |
* |
| 731 |
* @param \WP_REST_Request $request Unused. |
| 732 |
* @return \WP_REST_Response |
| 733 |
*/ |
| 734 |
public function rest_disconnect( \WP_REST_Request $request ) { |
| 735 |
unset( $request ); |
| 736 |
return rest_ensure_response( Mcp_Pairing::disconnect() ); |
| 737 |
} |
| 738 |
|
| 739 |
// -- xSpeed Hub (multi-site) handlers ---------------------------------- |
| 740 |
|
| 741 |
/** |
| 742 |
* GET /mcp/hub — hub-link status + the Method-1 paste-in values. |
| 743 |
* |
| 744 |
* @param \WP_REST_Request $request Unused. |
| 745 |
* @return \WP_REST_Response |
| 746 |
*/ |
| 747 |
public function rest_hub_status( \WP_REST_Request $request ) { |
| 748 |
// Self-heal from the Hub (source of truth) so the connected badge is |
| 749 |
// reliable even if the attach callback never fired. Force a fresh check |
| 750 |
// when the panel asks via the X-XSpeed-Reconcile header (e.g. the admin |
| 751 |
// returned to the tab after connecting). |
| 752 |
$force = '1' === (string) $request->get_header( 'x_xspeed_reconcile' ); |
| 753 |
Mcp_Hub::reconcile_with_hub( $force ); |
| 754 |
return rest_ensure_response( Mcp_Hub::public_status() ); |
| 755 |
} |
| 756 |
|
| 757 |
/** |
| 758 |
* POST /mcp/hub/token — ensure a site_token exists and return the |
| 759 |
* paste-in values (this site's URL + token) for the hub's Add-site form. |
| 760 |
* |
| 761 |
* @param \WP_REST_Request $request Unused. |
| 762 |
* @return \WP_REST_Response |
| 763 |
*/ |
| 764 |
public function rest_hub_token( \WP_REST_Request $request ) { |
| 765 |
unset( $request ); |
| 766 |
return rest_ensure_response( Mcp_Hub::generate_token() ); |
| 767 |
} |
| 768 |
|
| 769 |
/** |
| 770 |
* POST /mcp/hub/attached — record which hub account this site is |
| 771 |
* attached to (bookkeeping for the panel's status line). |
| 772 |
* |
| 773 |
* @param \WP_REST_Request $request Carries account_email. |
| 774 |
* @return \WP_REST_Response |
| 775 |
*/ |
| 776 |
public function rest_hub_attached( \WP_REST_Request $request ) { |
| 777 |
$email = sanitize_email( (string) $request->get_param( 'account_email' ) ); |
| 778 |
return rest_ensure_response( Mcp_Hub::mark_attached( $email ) ); |
| 779 |
} |
| 780 |
|
| 781 |
/** |
| 782 |
* POST /mcp/hub/disconnect — clear the local hub-link bookkeeping. |
| 783 |
* |
| 784 |
* @param \WP_REST_Request $request Unused. |
| 785 |
* @return \WP_REST_Response |
| 786 |
*/ |
| 787 |
public function rest_hub_disconnect( \WP_REST_Request $request ) { |
| 788 |
unset( $request ); |
| 789 |
return rest_ensure_response( Mcp_Hub::disconnect() ); |
| 790 |
} |
| 791 |
|
| 792 |
/** |
| 793 |
* POST /mcp/attach — the OAuth-attach callback. The hub presents the |
| 794 |
* signed nonce the plugin issued; on success we return this site's URL + |
| 795 |
* token so the hub can record it. Nonce is the auth (admin-minted, |
| 796 |
* HMAC-signed, time-bound), so no pre-shared token is required. |
| 797 |
* |
| 798 |
* @param \WP_REST_Request $request Carries the nonce. |
| 799 |
* @return \WP_REST_Response|\WP_Error |
| 800 |
*/ |
| 801 |
public function rest_hub_attach_callback( \WP_REST_Request $request ) { |
| 802 |
$nonce = (string) $request->get_param( 'nonce' ); |
| 803 |
$result = Mcp_Hub::verify_attach_nonce( $nonce ); |
| 804 |
if ( null === $result ) { |
| 805 |
return new \WP_Error( |
| 806 |
'xspeed_attach_invalid', |
| 807 |
__( 'Invalid or expired attach request.', 'xspeed' ), |
| 808 |
array( 'status' => 403 ) |
| 809 |
); |
| 810 |
} |
| 811 |
// A valid nonce proves this is a real hub-initiated attach, so record it |
| 812 |
// now — the hub passes the account email so the panel can show |
| 813 |
// "Connected via <email>". The nonce carries the minting admin's user |
| 814 |
// id (no WP session exists in this server-to-server call), so the state |
| 815 |
// is recorded PER-USER — each admin sees their own connection. |
| 816 |
$account_email = sanitize_email( (string) $request->get_param( 'account_email' ) ); |
| 817 |
$user_id = isset( $result['user_id'] ) ? (int) $result['user_id'] : 0; |
| 818 |
Mcp_Hub::mark_attached( $account_email, $user_id ?: null ); |
| 819 |
|
| 820 |
// The hub only needs the credential; don't leak the internal user id. |
| 821 |
unset( $result['user_id'] ); |
| 822 |
return rest_ensure_response( $result ); |
| 823 |
} |
| 824 |
|
| 825 |
// -- OAuth 2.1 handlers ------------------------------------------------ |
| 826 |
|
| 827 |
/** |
| 828 |
* POST /mcp/oauth/register — RFC 7591 dynamic client registration. |
| 829 |
* |
| 830 |
* @param \WP_REST_Request $request JSON body with redirect_uris. |
| 831 |
* @return \WP_REST_Response|\WP_Error |
| 832 |
*/ |
| 833 |
public function rest_oauth_register( \WP_REST_Request $request ) { |
| 834 |
$body = $request->get_json_params(); |
| 835 |
if ( ! is_array( $body ) ) { |
| 836 |
$body = array(); |
| 837 |
} |
| 838 |
$result = Mcp_OAuth::register_client( $body ); |
| 839 |
if ( is_wp_error( $result ) ) { |
| 840 |
return $result; |
| 841 |
} |
| 842 |
return new \WP_REST_Response( $result, 201 ); |
| 843 |
} |
| 844 |
|
| 845 |
/** |
| 846 |
* The browser-facing OAuth authorize page (served at /xspeed/authorize via |
| 847 |
* a rewrite, NOT the REST API — see AUTHORIZE_QUERY_VAR). Reads request |
| 848 |
* params from the superglobals because this is a normal front-end request |
| 849 |
* where cookie auth populates is_user_logged_in(). |
| 850 |
* |
| 851 |
* GET renders the consent screen (requires a logged-in admin; anonymous |
| 852 |
* users go to wp-login and return here). POST is the nonce-checked consent |
| 853 |
* submission: Approve issues a code and 302s to the client's redirect_uri; |
| 854 |
* Deny 302s back with error=access_denied. Always emits its own response |
| 855 |
* (HTML page or redirect) and exits. |
| 856 |
*/ |
| 857 |
public function handle_authorize_page(): void { |
| 858 |
$is_post = isset( $_SERVER['REQUEST_METHOD'] ) && 'POST' === strtoupper( (string) wp_unslash( $_SERVER['REQUEST_METHOD'] ) ); |
| 859 |
// Params come from GET on the consent link and POST on the form submit. |
| 860 |
// Nonce is verified below before any POST value is acted on. |
| 861 |
// phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing |
| 862 |
$source = $is_post ? $_POST : $_GET; |
| 863 |
// phpcs:enable |
| 864 |
$params = array(); |
| 865 |
foreach ( array( 'client_id', 'redirect_uri', 'response_type', 'code_challenge', 'code_challenge_method', 'scope', 'state', 'approve', 'deny', '_xspeed_oauth_nonce' ) as $k ) { |
| 866 |
$params[ $k ] = isset( $source[ $k ] ) ? sanitize_text_field( wp_unslash( $source[ $k ] ) ) : ''; |
| 867 |
} |
| 868 |
|
| 869 |
// Validate the OAuth params before touching the session. |
| 870 |
$req = Mcp_OAuth::validate_authorize_request( $params ); |
| 871 |
if ( is_wp_error( $req ) ) { |
| 872 |
$data = $req->get_error_data(); |
| 873 |
$redirectable = is_array( $data ) && ! empty( $data['redirectable'] ); |
| 874 |
// Only redirect the error back when redirect_uri is verified valid; |
| 875 |
// otherwise show a page (never bounce to an unverified URL). |
| 876 |
if ( $redirectable && '' !== $params['redirect_uri'] ) { |
| 877 |
$this->redirect_error( $params['redirect_uri'], $req->get_error_code(), $req->get_error_message(), $params['state'] ); |
| 878 |
} |
| 879 |
$this->emit_oauth_error_page( $req->get_error_message() ); |
| 880 |
} |
| 881 |
|
| 882 |
// Require a logged-in admin. Anonymous → wp-login, back to this URL. |
| 883 |
if ( ! is_user_logged_in() ) { |
| 884 |
$this->redirect_to_login(); |
| 885 |
} |
| 886 |
if ( ! current_user_can( 'manage_options' ) ) { |
| 887 |
$this->emit_oauth_error_page( |
| 888 |
__( 'You must be an administrator to authorize an AI agent to control this site.', 'xspeed' ) |
| 889 |
); |
| 890 |
} |
| 891 |
|
| 892 |
// POST = consent form submitted. |
| 893 |
if ( $is_post ) { |
| 894 |
if ( ! wp_verify_nonce( $params['_xspeed_oauth_nonce'], 'xspeed_oauth_consent' ) ) { |
| 895 |
$this->emit_oauth_error_page( __( 'Security check failed. Please try connecting again.', 'xspeed' ) ); |
| 896 |
} |
| 897 |
if ( '' === $params['approve'] ) { |
| 898 |
$this->redirect_error( $req['redirect_uri'], 'access_denied', 'The user denied the request.', $req['state'] ); |
| 899 |
} |
| 900 |
$code = Mcp_OAuth::issue_code( $req, get_current_user_id() ); |
| 901 |
$this->redirect_success( $req['redirect_uri'], $code, $req['state'] ); |
| 902 |
} |
| 903 |
|
| 904 |
// GET = render the consent screen. |
| 905 |
$this->emit_consent_screen( $req ); |
| 906 |
} |
| 907 |
|
| 908 |
/** |
| 909 |
* POST /mcp/oauth/token — exchange a code (or refresh token) for tokens. |
| 910 |
* |
| 911 |
* @param \WP_REST_Request $request Form-encoded or JSON token request. |
| 912 |
* @return \WP_REST_Response |
| 913 |
*/ |
| 914 |
public function rest_oauth_token( \WP_REST_Request $request ) { |
| 915 |
// Token requests are application/x-www-form-urlencoded per OAuth, but |
| 916 |
// accept JSON too. get_body_params() covers the form case. |
| 917 |
$body = $request->get_body_params(); |
| 918 |
if ( empty( $body ) ) { |
| 919 |
$json = $request->get_json_params(); |
| 920 |
$body = is_array( $json ) ? $json : array(); |
| 921 |
} |
| 922 |
$body = array_map( 'strval', $body ); |
| 923 |
|
| 924 |
$result = Mcp_OAuth::exchange_token( $body ); |
| 925 |
if ( is_wp_error( $result ) ) { |
| 926 |
$data = $result->get_error_data(); |
| 927 |
$response = new \WP_REST_Response( |
| 928 |
array( |
| 929 |
'error' => isset( $data['error'] ) ? $data['error'] : 'invalid_request', |
| 930 |
'error_description' => isset( $data['error_description'] ) ? $data['error_description'] : $result->get_error_message(), |
| 931 |
), |
| 932 |
isset( $data['status'] ) ? (int) $data['status'] : 400 |
| 933 |
); |
| 934 |
$response->header( 'Cache-Control', 'no-store' ); |
| 935 |
return $response; |
| 936 |
} |
| 937 |
$response = new \WP_REST_Response( $result, 200 ); |
| 938 |
$response->header( 'Cache-Control', 'no-store' ); |
| 939 |
$response->header( 'Pragma', 'no-cache' ); |
| 940 |
return $response; |
| 941 |
} |
| 942 |
|
| 943 |
/** |
| 944 |
* Token-authenticated tool route for the hosted broker. Maps a broker |
| 945 |
* tool call (e.g. GET /mcp/tool/get_cache_status) onto the shared |
| 946 |
* Mcp_Tools catalog, so the broker path and the JSON-RPC path never |
| 947 |
* drift. GET params + JSON body both feed the tool's arguments. |
| 948 |
*/ |
| 949 |
public function rest_tool( \WP_REST_Request $request ) { |
| 950 |
$tool = (string) $request->get_param( 'tool' ); |
| 951 |
$args = $request->get_json_params(); |
| 952 |
if ( ! is_array( $args ) ) { |
| 953 |
$args = array(); |
| 954 |
} |
| 955 |
// Merge query params (e.g. ?module=minify) so GET tools work too. |
| 956 |
foreach ( $request->get_query_params() as $k => $v ) { |
| 957 |
if ( 'tool' !== $k && ! array_key_exists( $k, $args ) ) { |
| 958 |
$args[ $k ] = $v; |
| 959 |
} |
| 960 |
} |
| 961 |
|
| 962 |
Mcp_Tools::set_channel( 'broker' ); |
| 963 |
$result = Mcp_Tools::invoke( $tool, $args ); |
| 964 |
if ( is_wp_error( $result ) ) { |
| 965 |
return $result; |
| 966 |
} |
| 967 |
return rest_ensure_response( $result ); |
| 968 |
} |
| 969 |
|
| 970 |
// -- Helpers -- |
| 971 |
|
| 972 |
// -- OAuth browser-response helpers ------------------------------------ |
| 973 |
|
| 974 |
/** The absolute URL of the current authorize request (for login return). */ |
| 975 |
private function current_authorize_url(): string { |
| 976 |
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- reconstructing the current URL for a login round-trip; escaped at use. |
| 977 |
$uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : ''; |
| 978 |
return home_url( $uri ); |
| 979 |
} |
| 980 |
|
| 981 |
/** Send an anonymous visitor to wp-login, returning to this authorize URL. */ |
| 982 |
private function redirect_to_login(): void { |
| 983 |
wp_safe_redirect( wp_login_url( $this->current_authorize_url() ) ); |
| 984 |
exit; |
| 985 |
} |
| 986 |
|
| 987 |
/** 302 back to the client with the authorization code (+ state). */ |
| 988 |
private function redirect_success( string $redirect_uri, string $code, string $state ): void { |
| 989 |
$args = array( 'code' => $code ); |
| 990 |
if ( '' !== $state ) { |
| 991 |
$args['state'] = $state; |
| 992 |
} |
| 993 |
// Not wp_safe_redirect: redirect_uri is a client-registered off-site |
| 994 |
// callback, already validated against the client's registered set. |
| 995 |
wp_redirect( add_query_arg( $args, $redirect_uri ) ); // phpcs:ignore WordPress.Security.SafeRedirect -- validated OAuth redirect_uri. |
| 996 |
exit; |
| 997 |
} |
| 998 |
|
| 999 |
/** 302 back to the client with an OAuth error (+ state). */ |
| 1000 |
private function redirect_error( string $redirect_uri, string $error, string $description, string $state ): void { |
| 1001 |
$args = array( |
| 1002 |
'error' => $error, |
| 1003 |
'error_description' => $description, |
| 1004 |
); |
| 1005 |
if ( '' !== $state ) { |
| 1006 |
$args['state'] = $state; |
| 1007 |
} |
| 1008 |
wp_redirect( add_query_arg( array_map( 'rawurlencode', $args ), $redirect_uri ) ); // phpcs:ignore WordPress.Security.SafeRedirect -- validated OAuth redirect_uri. |
| 1009 |
exit; |
| 1010 |
} |
| 1011 |
|
| 1012 |
/** |
| 1013 |
* Render the consent screen. Minimal self-contained HTML (no admin |
| 1014 |
* chrome — this is a client-facing OAuth page). Approve/Deny post back |
| 1015 |
* to the same authorize URL with a nonce. |
| 1016 |
* |
| 1017 |
* @param array<string,string> $req Validated authorize params. |
| 1018 |
*/ |
| 1019 |
private function emit_consent_screen( array $req ): void { |
| 1020 |
$read_only = Mcp_OAuth::scope_is_read_only( $req['scope'] ); |
| 1021 |
$access = $read_only |
| 1022 |
? __( 'Read-only — inspect cache status and settings.', 'xspeed' ) |
| 1023 |
: __( 'Read & write — purge caches, toggle caching, and change settings.', 'xspeed' ); |
| 1024 |
$client = '' !== $req['client_name'] ? $req['client_name'] : __( 'An AI agent', 'xspeed' ); |
| 1025 |
$action_url = Mcp_OAuth::authorize_url(); |
| 1026 |
$nonce = wp_create_nonce( 'xspeed_oauth_consent' ); |
| 1027 |
$user = wp_get_current_user(); |
| 1028 |
|
| 1029 |
// Preserve every OAuth param so the POST re-validates identically. |
| 1030 |
$hidden = ''; |
| 1031 |
foreach ( array( 'client_id', 'redirect_uri', 'code_challenge', 'scope', 'state' ) as $k ) { |
| 1032 |
$val = 'scope' === $k ? $req['scope'] : ( $req[ $k ] ?? '' ); |
| 1033 |
$hidden .= sprintf( '<input type="hidden" name="%s" value="%s" />', esc_attr( $k ), esc_attr( (string) $val ) ); |
| 1034 |
} |
| 1035 |
// code_challenge_method + response_type are re-asserted for validation. |
| 1036 |
$hidden .= '<input type="hidden" name="code_challenge_method" value="S256" />'; |
| 1037 |
$hidden .= '<input type="hidden" name="response_type" value="code" />'; |
| 1038 |
|
| 1039 |
status_header( 200 ); |
| 1040 |
header( 'Content-Type: text/html; charset=utf-8' ); |
| 1041 |
header( 'Cache-Control: no-store' ); |
| 1042 |
|
| 1043 |
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>'; |
| 1044 |
echo '<style>' |
| 1045 |
. '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}' |
| 1046 |
. '.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)}' |
| 1047 |
. 'h1{font-size:20px;margin:0 0 4px}.sub{color:#94a3b8;font-size:13px;margin:0 0 24px}' |
| 1048 |
. '.row{display:flex;justify-content:space-between;padding:10px 0;border-bottom:1px solid #334155;font-size:13px}' |
| 1049 |
. '.row span:first-child{color:#94a3b8}.row span:last-child{font-weight:600;text-align:right;max-width:60%;word-break:break-word}' |
| 1050 |
. '.actions{display:flex;gap:12px;margin-top:24px}' |
| 1051 |
. 'button{flex:1;padding:12px;border-radius:10px;border:0;font-size:14px;font-weight:600;cursor:pointer}' |
| 1052 |
. '.approve{background:#f5cd47;color:#1b2533}.deny{background:transparent;color:#94a3b8;border:1px solid #334155}' |
| 1053 |
. '</style></head><body><div class="card">'; |
| 1054 |
echo '<h1>' . esc_html__( 'Connect to xSpeed', 'xspeed' ) . '</h1>'; |
| 1055 |
/* translators: %s: AI client name. */ |
| 1056 |
echo '<p class="sub">' . esc_html( sprintf( __( '%s wants to manage the cache on this site.', 'xspeed' ), $client ) ) . '</p>'; |
| 1057 |
echo '<div class="row"><span>' . esc_html__( 'Site', 'xspeed' ) . '</span><span>' . esc_html( wp_parse_url( home_url(), PHP_URL_HOST ) ) . '</span></div>'; |
| 1058 |
echo '<div class="row"><span>' . esc_html__( 'Signed in as', 'xspeed' ) . '</span><span>' . esc_html( $user->user_login ) . '</span></div>'; |
| 1059 |
echo '<div class="row"><span>' . esc_html__( 'Access', 'xspeed' ) . '</span><span>' . esc_html( $access ) . '</span></div>'; |
| 1060 |
echo '<form method="post" action="' . esc_url( $action_url ) . '">'; |
| 1061 |
echo $hidden; // phpcs:ignore WordPress.Security.EscapeOutput -- built from esc_attr() above. |
| 1062 |
echo '<input type="hidden" name="_xspeed_oauth_nonce" value="' . esc_attr( $nonce ) . '" />'; |
| 1063 |
echo '<div class="actions">'; |
| 1064 |
echo '<button class="deny" name="deny" value="1">' . esc_html__( 'Deny', 'xspeed' ) . '</button>'; |
| 1065 |
echo '<button class="approve" name="approve" value="1">' . esc_html__( 'Approve', 'xspeed' ) . '</button>'; |
| 1066 |
echo '</div></form></div></body></html>'; |
| 1067 |
exit; |
| 1068 |
} |
| 1069 |
|
| 1070 |
/** Render a standalone OAuth error page (no redirect). */ |
| 1071 |
private function emit_oauth_error_page( string $message ): void { |
| 1072 |
status_header( 400 ); |
| 1073 |
header( 'Content-Type: text/html; charset=utf-8' ); |
| 1074 |
header( 'Cache-Control: no-store' ); |
| 1075 |
echo '<!doctype html><html><head><meta charset="utf-8"><title>' . esc_html__( 'Authorization error', 'xspeed' ) . '</title>'; |
| 1076 |
echo '<style>body{font:15px/1.5 -apple-system,sans-serif;background:#0f172a;color:#e2e8f0;display:flex;min-height:100vh;align-items:center;justify-content:center;margin:0}' |
| 1077 |
. '.card{background:#1e293b;border:1px solid #334155;border-radius:16px;max-width:440px;padding:32px;text-align:center}</style></head><body>'; |
| 1078 |
echo '<div class="card"><h1>' . esc_html__( 'Could not authorize', 'xspeed' ) . '</h1><p>' . esc_html( $message ) . '</p></div></body></html>'; |
| 1079 |
exit; |
| 1080 |
} |
| 1081 |
|
| 1082 |
/** Read an inbound HTTP header from $_SERVER (for the pretty path). */ |
| 1083 |
private static function server_header( string $name ): ?string { |
| 1084 |
$key = 'HTTP_' . strtoupper( str_replace( '-', '_', $name ) ); |
| 1085 |
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- token compared constant-time downstream; raw header needed verbatim. |
| 1086 |
return isset( $_SERVER[ $key ] ) ? wp_unslash( $_SERVER[ $key ] ) : null; |
| 1087 |
} |
| 1088 |
|
| 1089 |
/** Emit a WP_REST_Response as a JSON HTTP response and stop. */ |
| 1090 |
private function emit_json( \WP_REST_Response $response ): void { |
| 1091 |
status_header( $response->get_status() ); |
| 1092 |
// MCP Streamable HTTP: advertise the protocol version we speak so a |
| 1093 |
// strict client can pin it. We answer JSON (a spec-permitted response |
| 1094 |
// type); we never open an SSE stream, so no session header is needed. |
| 1095 |
header( 'MCP-Protocol-Version: ' . Mcp_Server::PROTOCOL_VERSION ); |
| 1096 |
// Forward any headers the handler set (notably WWW-Authenticate on a |
| 1097 |
// 401, which drives the OAuth discovery flow). rest_do_request applies |
| 1098 |
// these automatically; the pretty-endpoint path must do it by hand. |
| 1099 |
foreach ( $response->get_headers() as $name => $value ) { |
| 1100 |
header( $name . ': ' . $value ); |
| 1101 |
} |
| 1102 |
$data = $response->get_data(); |
| 1103 |
if ( null !== $data ) { |
| 1104 |
header( 'Content-Type: application/json; charset=utf-8' ); |
| 1105 |
echo wp_json_encode( $data ); |
| 1106 |
} |
| 1107 |
exit; |
| 1108 |
} |
| 1109 |
|
| 1110 |
// -- WP-CLI mirror -- |
| 1111 |
|
| 1112 |
public function cli_commands(): array { |
| 1113 |
return array( |
| 1114 |
array( |
| 1115 |
'name' => 'xspeed mcp status', |
| 1116 |
'callback' => array( $this, 'cli_status' ), |
| 1117 |
'shortdesc' => 'Show MCP connection status and the paste-in endpoint URL.', |
| 1118 |
'synopsis' => array(), |
| 1119 |
), |
| 1120 |
array( |
| 1121 |
'name' => 'xspeed mcp activity', |
| 1122 |
'callback' => array( $this, 'cli_activity' ), |
| 1123 |
'shortdesc' => 'List recent MCP tool calls (the AI audit trail).', |
| 1124 |
'synopsis' => array( |
| 1125 |
array( |
| 1126 |
'name' => 'limit', |
| 1127 |
'type' => 'assoc', |
| 1128 |
'optional' => true, |
| 1129 |
'description' => 'Maximum entries to show (default 20).', |
| 1130 |
), |
| 1131 |
array( |
| 1132 |
'name' => 'clear', |
| 1133 |
'type' => 'flag', |
| 1134 |
'optional' => true, |
| 1135 |
'description' => 'Wipe the audit trail instead of listing it.', |
| 1136 |
), |
| 1137 |
), |
| 1138 |
), |
| 1139 |
array( |
| 1140 |
'name' => 'xspeed mcp connect', |
| 1141 |
'callback' => array( $this, 'cli_connect' ), |
| 1142 |
'shortdesc' => 'Generate a connection token for this site\'s MCP endpoint.', |
| 1143 |
'synopsis' => array( |
| 1144 |
array( |
| 1145 |
'name' => 'read-only', |
| 1146 |
'type' => 'flag', |
| 1147 |
'optional' => true, |
| 1148 |
'description' => 'Grant read-only access (no purge/toggle/settings changes).', |
| 1149 |
), |
| 1150 |
), |
| 1151 |
), |
| 1152 |
array( |
| 1153 |
'name' => 'xspeed mcp rotate', |
| 1154 |
'callback' => array( $this, 'cli_rotate' ), |
| 1155 |
'shortdesc' => 'Mint a fresh MCP token, immediately invalidating the previous one.', |
| 1156 |
'synopsis' => array( |
| 1157 |
array( |
| 1158 |
'name' => 'read-only', |
| 1159 |
'type' => 'flag', |
| 1160 |
'optional' => true, |
| 1161 |
'description' => 'Make the new token read-only.', |
| 1162 |
), |
| 1163 |
), |
| 1164 |
), |
| 1165 |
array( |
| 1166 |
'name' => 'xspeed mcp disconnect', |
| 1167 |
'callback' => array( $this, 'cli_disconnect' ), |
| 1168 |
'shortdesc' => 'Revoke this site\'s MCP connection token.', |
| 1169 |
'synopsis' => array(), |
| 1170 |
), |
| 1171 |
); |
| 1172 |
} |
| 1173 |
|
| 1174 |
/** |
| 1175 |
* `wp xspeed mcp status` — print connection status + endpoint URL. |
| 1176 |
* |
| 1177 |
* @param array $args Positional args (unused). |
| 1178 |
* @param array $assoc Associative args (unused). |
| 1179 |
*/ |
| 1180 |
public function cli_status( array $args, array $assoc ): void { |
| 1181 |
unset( $args, $assoc ); |
| 1182 |
$s = Mcp_Pairing::public_status(); |
| 1183 |
\WP_CLI::log( sprintf( '%-18s %s', 'connected', $s['connected'] ? 'yes' : 'no' ) ); |
| 1184 |
if ( $s['connected'] ) { |
| 1185 |
\WP_CLI::log( sprintf( '%-18s %s', 'access', $s['read_only'] ? 'read-only' : 'read-write' ) ); |
| 1186 |
\WP_CLI::log( sprintf( '%-18s %s', 'connect_url', $s['connect_url'] ) ); |
| 1187 |
\WP_CLI::log( sprintf( '%-18s %s', 'scopes', implode( ',', $s['scopes'] ) ) ); |
| 1188 |
} else { |
| 1189 |
\WP_CLI::log( sprintf( '%-18s %s', 'mcp_endpoint', Mcp_Pairing::site_endpoint() ) ); |
| 1190 |
} |
| 1191 |
} |
| 1192 |
|
| 1193 |
/** |
| 1194 |
* `wp xspeed mcp activity` — read (or clear) the AI audit trail. |
| 1195 |
* |
| 1196 |
* @param array $args Positional args (unused). |
| 1197 |
* @param array $assoc --limit=<n>, --clear. |
| 1198 |
*/ |
| 1199 |
public function cli_activity( array $args, array $assoc ): void { |
| 1200 |
unset( $args ); |
| 1201 |
|
| 1202 |
if ( ! empty( $assoc['clear'] ) ) { |
| 1203 |
if ( ! Mcp_Activity_Log::clear() ) { |
| 1204 |
// Reached via MCP run_command — the assistant is asking to |
| 1205 |
// erase the record of its own calls. Mcp_Activity_Log::clear() |
| 1206 |
// declines and logs the attempt; say so plainly. |
| 1207 |
\WP_CLI::error( 'The MCP activity log cannot be cleared from an MCP tool call. Clear it from the xSpeed dashboard or from WP-CLI on the server.' ); |
| 1208 |
return; |
| 1209 |
} |
| 1210 |
\WP_CLI::success( 'MCP activity log cleared.' ); |
| 1211 |
return; |
| 1212 |
} |
| 1213 |
|
| 1214 |
$limit = isset( $assoc['limit'] ) ? (int) $assoc['limit'] : 20; |
| 1215 |
$summary = Mcp_Activity_Log::summary(); |
| 1216 |
$entries = Mcp_Activity_Log::entries( $limit > 0 ? $limit : 20 ); |
| 1217 |
|
| 1218 |
\WP_CLI::log( sprintf( '%-18s %d', 'total_calls', $summary['total'] ) ); |
| 1219 |
\WP_CLI::log( sprintf( '%-18s %d', 'failed', $summary['failed'] ) ); |
| 1220 |
\WP_CLI::log( sprintf( '%-18s %s', 'top_tool', '' === $summary['top_tool'] ? '-' : $summary['top_tool'] ) ); |
| 1221 |
|
| 1222 |
if ( empty( $entries ) ) { |
| 1223 |
\WP_CLI::log( '' ); |
| 1224 |
\WP_CLI::log( 'No MCP tool calls recorded yet.' ); |
| 1225 |
return; |
| 1226 |
} |
| 1227 |
|
| 1228 |
\WP_CLI::log( '' ); |
| 1229 |
foreach ( $entries as $entry ) { |
| 1230 |
\WP_CLI::log( |
| 1231 |
sprintf( |
| 1232 |
'%s %-22s %-5s %-6s %s%s', |
| 1233 |
gmdate( 'Y-m-d H:i:s', $entry['ts'] ), |
| 1234 |
$entry['tool'], |
| 1235 |
$entry['scope'], |
| 1236 |
$entry['ok'] ? 'ok' : 'FAIL', |
| 1237 |
$entry['args'], |
| 1238 |
'' === $entry['error'] ? '' : ' — ' . $entry['error'] |
| 1239 |
) |
| 1240 |
); |
| 1241 |
} |
| 1242 |
} |
| 1243 |
|
| 1244 |
/** |
| 1245 |
* `wp xspeed mcp connect` — mint a token and print the paste-in URL. |
| 1246 |
* |
| 1247 |
* @param array $args Positional args (unused). |
| 1248 |
* @param array $assoc Associative args (unused). |
| 1249 |
*/ |
| 1250 |
public function cli_connect( array $args, array $assoc ): void { |
| 1251 |
unset( $args ); |
| 1252 |
$read_only = ! empty( $assoc['read-only'] ); |
| 1253 |
$result = Mcp_Pairing::connect( $read_only ); |
| 1254 |
if ( is_wp_error( $result ) ) { |
| 1255 |
\WP_CLI::error( $result->get_error_message() ); |
| 1256 |
return; |
| 1257 |
} |
| 1258 |
\WP_CLI::success( 'Connected' . ( Mcp_Pairing::is_read_only() ? ' (read-only).' : '.' ) . ' Paste this single URL into your AI client:' ); |
| 1259 |
\WP_CLI::log( ' ' . Mcp_Pairing::connect_url() ); |
| 1260 |
\WP_CLI::log( '' ); |
| 1261 |
\WP_CLI::log( 'Or, header-based (token stays out of the URL):' ); |
| 1262 |
\WP_CLI::log( ' ' . Mcp_Pairing::config_snippets()['cli'] ); |
| 1263 |
} |
| 1264 |
|
| 1265 |
/** |
| 1266 |
* `wp xspeed mcp rotate` — mint a new token, revoking the old one. |
| 1267 |
* |
| 1268 |
* @param array $args Positional args (unused). |
| 1269 |
* @param array $assoc Associative args ({ read-only?:flag }). |
| 1270 |
*/ |
| 1271 |
public function cli_rotate( array $args, array $assoc ): void { |
| 1272 |
unset( $args ); |
| 1273 |
$read_only = array_key_exists( 'read-only', $assoc ) ? ! empty( $assoc['read-only'] ) : null; |
| 1274 |
Mcp_Pairing::rotate( $read_only ); |
| 1275 |
\WP_CLI::success( 'Rotated. The previous token is now invalid. New paste-in URL:' ); |
| 1276 |
\WP_CLI::log( ' ' . Mcp_Pairing::connect_url() ); |
| 1277 |
} |
| 1278 |
|
| 1279 |
/** |
| 1280 |
* `wp xspeed mcp disconnect` — revoke the connection token. |
| 1281 |
* |
| 1282 |
* @param array $args Positional args (unused). |
| 1283 |
* @param array $assoc Associative args (unused). |
| 1284 |
*/ |
| 1285 |
public function cli_disconnect( array $args, array $assoc ): void { |
| 1286 |
unset( $args, $assoc ); |
| 1287 |
Mcp_Pairing::disconnect(); |
| 1288 |
\WP_CLI::success( 'Disconnected and revoked the MCP token.' ); |
| 1289 |
} |
| 1290 |
} |
| 1291 |
|