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