| @@ -36,21 +36,96 @@ | ||
| 36 | 36 | declare(strict_types=1); |
| 37 | 37 | |
| 38 | 38 | namespace XSpeed\Modules\Mcp; |
| 39 | 39 | |
| 40 | +use XSpeed\Activity_Log; | |
| 40 | 41 | use XSpeed\Module; |
| 42 | +use XSpeed\Onboarding; | |
| 41 | 43 | |
| 42 | 44 | defined( 'ABSPATH' ) || exit; |
| 43 | 45 | |
| 44 | 46 | final class McpModule extends Module { |
| 45 | 47 | |
| 48 | + /** | |
| 49 | + * The stylesheet of the standalone consent pages: the OAuth authorize | |
| 50 | + * screen here and the Hub connect screen (Mcp_Hub_Connect). | |
| 51 | + */ | |
| 52 | + public const CONSENT_CSS = 'body{font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#0f172a;color:#e2e8f0;margin:0;display:flex;min-height:100vh;align-items:center;justify-content:center}.card{background:#1e293b;border:1px solid #334155;border-radius:16px;max-width:440px;padding:32px;box-shadow:0 10px 40px rgba(0,0,0,.4)}h1{font-size:20px;margin:0 0 4px}.sub{color:#94a3b8;font-size:13px;margin:0 0 24px}.row{display:flex;justify-content:space-between;padding:10px 0;border-bottom:1px solid #334155;font-size:13px}.row span:first-child{color:#94a3b8}.row span:last-child{font-weight:600;text-align:right;max-width:60%;word-break:break-word}.actions{display:flex;gap:12px;margin-top:24px}button{flex:1;padding:12px;border-radius:10px;border:0;font-size:14px;font-weight:600;cursor:pointer}.approve{background:#f5cd47;color:#1b2533}.deny{background:transparent;color:#94a3b8;border:1px solid #334155}'; | |
| 53 | + | |
| 46 | 54 | public const SLUG = 'mcp'; |
| 47 | 55 | public const TIER = self::TIER_FREE; |
| 48 | 56 | public const VERSION = '1.0.0'; |
| 49 | 57 | |
| 50 | - /** REST namespace shared with Free. */ | |
| 51 | - private const NS = 'xspeed/v1'; | |
| 58 | + /** | |
| 59 | + * Every rewrite rule this module registers, in registration order. | |
| 60 | + * | |
| 61 | + * Single source of truth: add_rewrite() registers these, and the self-heal | |
| 62 | + * guard re-flushes when any is missing from the stored table. They were two | |
| 63 | + * hand-maintained lists before, which is a silent drift risk — a rule | |
| 64 | + * dropped from one and not the other leaves the guard restoring a rule | |
| 65 | + * nothing registers, or never firing for one that is registered. | |
| 66 | + * | |
| 67 | + * @var string[] Rewrite regexes. The query each maps to is built in | |
| 68 | + * add_rewrite(), which also fixes their order. | |
| 69 | + */ | |
| 70 | + public const REWRITE_RULES = array( | |
| 71 | + '^xspeed/mcp/([a-f0-9]{64})/?$', | |
| 72 | + '^xspeed/mcp/?$', | |
| 73 | + '^xspeed/mcp/attach/?$', | |
| 74 | + '^xspeed/mcp/connect-info/?$', | |
| 75 | + // OAuth discovery. RFC 9728 §3.1 / RFC 8414 §3.1 put the | |
| 76 | + // `.well-known` segment BEFORE the resource/issuer path, and both of | |
| 77 | + // our identifiers are /xspeed/mcp — so this pair of URLs, and only | |
| 78 | + // this pair, is ours. It names our own path explicitly: a catch-all | |
| 79 | + // tail here also matched other MCP plugins' discovery URLs on the | |
| 80 | + // same site and answered them with our metadata, which broke their | |
| 81 | + // connectors. The bare root form is registered conditionally and so | |
| 82 | + // lives apart, in ROOT_DISCOVERY_RULE. | |
| 83 | + '^\.well-known/oauth-(protected-resource|authorization-server)/xspeed/mcp/?$', | |
| 84 | + '^xspeed/authorize/?$', | |
| 85 | + ); | |
| 52 | 86 | |
| 87 | + /** | |
| 88 | + * The root-form discovery rule — registered CONDITIONALLY, which is why | |
| 89 | + * it is not in REWRITE_RULES. | |
| 90 | + * | |
| 91 | + * It is the address a client built to the 2025-03-26 MCP spec looks at, | |
| 92 | + * and the only address it looks at; current clients read the | |
| 93 | + * protected-resource document first and follow it to the path form. So | |
| 94 | + * dropping it outright would cut off older clients on every site, | |
| 95 | + * including the single-plugin sites where the collision #266 exists to | |
| 96 | + * fix never happened. We answer it while it is uncontested and stand | |
| 97 | + * down the moment another plugin's rule claims it — | |
| 98 | + * root_discovery_contested(). | |
| 99 | + */ | |
| 100 | + public const ROOT_DISCOVERY_RULE = '^\.well-known/oauth-(protected-resource|authorization-server)/?$'; | |
| 101 | + | |
| 102 | + /** | |
| 103 | + * Rules earlier builds registered that we never register again under any | |
| 104 | + * condition. The self-heal guard flushes once when it finds OUR copy of | |
| 105 | + * one still in the stored table. | |
| 106 | + * | |
| 107 | + * The catch-all below shipped in an intermediate build and matched every | |
| 108 | + * path-suffixed discovery URL on the site, including other MCP plugins' | |
| 109 | + * (#264). Nothing brings it back, so its removal is unconditional — | |
| 110 | + * unlike ROOT_DISCOVERY_RULE, which is a rule we still register when the | |
| 111 | + * root URL is uncontested and therefore cannot live in this list. | |
| 112 | + * | |
| 113 | + * Ownership is read from the rule's TARGET, never from the regex alone: | |
| 114 | + * a sibling may register the same regex for its own document, its rule | |
| 115 | + * comes back from every flush, and a guard that treated that as stale | |
| 116 | + * would flush on every request forever. | |
| 117 | + * | |
| 118 | + * @var string[] | |
| 119 | + */ | |
| 120 | + public const RETIRED_REWRITE_RULES = array( | |
| 121 | + '^\.well-known/oauth-(protected-resource|authorization-server)(?:/.*)?/?$', | |
| 122 | + ); | |
| 123 | + | |
| 124 | + /** REST namespace shared with Free. Public: Mcp_Server builds the | |
| 125 | + * discovery fallback URL from it. */ | |
| 126 | + public const NS = 'xspeed/v1'; | |
| 127 | + | |
| 53 | 128 | /** Query var flagging a pretty /xspeed/mcp request. */ |
| 54 | 129 | private const QUERY_VAR = 'xspeed_mcp'; |
| 55 | 130 | |
| 56 | 131 | /** Query var carrying the token when embedded in the URL path. */ |
| @@ -75,14 +150,18 @@ | ||
| 75 | 150 | |
| 76 | 151 | /** Query var flagging the pretty /xspeed/mcp/attach callback. */ |
| 77 | 152 | private const ATTACH_QUERY_VAR = 'xspeed_mcp_attach'; |
| 78 | 153 | |
| 154 | + /** Query var flagging the pretty /xspeed/mcp/connect-info document (xspeed-hub#307). */ | |
| 155 | + private const CONNECT_INFO_QUERY_VAR = 'xspeed_mcp_connect_info'; | |
| 156 | + | |
| 79 | 157 | public function ui_metadata(): array { |
| 80 | 158 | return array( |
| 81 | - 'label' => 'MCP Server', | |
| 159 | + 'label' => __( 'MCP Server', 'xspeed' ), | |
| 82 | 160 | 'icon' => 'Sparkles', |
| 83 | - 'description' => 'Control this site\'s cache from Claude and other AI agents.', | |
| 161 | + 'description' => __( 'Let Claude or another AI assistant clear the cache, check stats and change settings.', 'xspeed' ), | |
| 84 | 162 | 'custom_panel' => 'McpPanel', |
| 163 | + 'group' => 'ai-agents', | |
| 85 | 164 | ); |
| 86 | 165 | } |
| 87 | 166 | |
| 88 | 167 | /** |
| @@ -94,8 +173,31 @@ | ||
| 94 | 173 | return array(); |
| 95 | 174 | } |
| 96 | 175 | |
| 97 | 176 | /** |
| 177 | + * The pairing state, declared so a settings write cannot delete it. | |
| 178 | + * | |
| 179 | + * `Mcp_Pairing` keeps its credentials in this module's settings option, | |
| 180 | + * and the schema above is empty — so `Settings_Manager::get()` strips | |
| 181 | + * every one of these keys, and an `update()` for this slug then writes | |
| 182 | + * that stripped array back. The connection token, the scopes and the | |
| 183 | + * connected flag all disappear in a single save, and the only visible | |
| 184 | + * result is a site that has silently lost MCP access while the Hub still | |
| 185 | + * lists it as attached. | |
| 186 | + * | |
| 187 | + * Nothing offers a settings form for this module, which is why it went | |
| 188 | + * unnoticed, but `update()` takes its slug from data on three paths that | |
| 189 | + * do not: a recommendation action, an optimize plan, and the MCP | |
| 190 | + * `update_settings` tool. `preserved_keys()` is the mechanism for exactly | |
| 191 | + * this — out-of-schema keys a schema-driven save must carry through. | |
| 192 | + * | |
| 193 | + * @return string[] | |
| 194 | + */ | |
| 195 | + public function preserved_keys(): array { | |
| 196 | + return array( 'site_token', 'connection_token', 'connected', 'connected_at', 'scopes' ); | |
| 197 | + } | |
| 198 | + | |
| 199 | + /** | |
| 98 | 200 | * All MCP routes register directly (see class docblock). Returning an |
| 99 | 201 | * empty array keeps Rest_Manager out of the token-auth path entirely. |
| 100 | 202 | */ |
| 101 | 203 | public function rest_routes(): array { |
| @@ -103,16 +205,133 @@ | ||
| 103 | 205 | } |
| 104 | 206 | |
| 105 | 207 | public function boot(): void { |
| 106 | 208 | add_action( 'rest_api_init', array( $this, 'register_rest' ) ); |
| 209 | + add_action( Mcp_Pairing::TOKEN_CHANGED_ACTION, array( Mcp_Hub::class, 'on_token_changed' ) ); | |
| 107 | 210 | |
| 211 | + // The only place the body cap can run before WordPress decodes it. | |
| 212 | + // WP_REST_Server::dispatch() fires rest_pre_dispatch, and only after | |
| 213 | + // that calls has_valid_params() — which json_decode()s the whole body | |
| 214 | + // for any application/json request, ahead of the permission callback | |
| 215 | + // and the handler. A cap inside a handler is therefore a second line, | |
| 216 | + // not the bound it reads like. | |
| 217 | + add_filter( 'rest_pre_dispatch', array( $this, 'cap_request_body' ), 10, 3 ); | |
| 218 | + | |
| 108 | 219 | // Pretty per-site endpoint: /xspeed/mcp → MCP JSON-RPC handler. |
| 109 | - add_action( 'init', array( $this, 'add_rewrite' ) ); | |
| 220 | + // `wp_loaded`, not `init`: add_rewrite() decides whether to claim the | |
| 221 | + // root discovery URL by looking at the rewrite table, and on `init` that | |
| 222 | + // view is incomplete -- a sibling MCP plugin hooked at the same priority | |
| 223 | + // but loaded after us has not registered yet. Root then looks | |
| 224 | + // uncontested, we register our rule, and the self-heal guard concludes | |
| 225 | + // nothing is stale, so it never flushes. That is a fixed point: the | |
| 226 | + // table never converges, and because our rule is the one WordPress | |
| 227 | + // matches, the sibling never sees the request either. | |
| 228 | + // | |
| 229 | + // By `wp_loaded` every init callback on every request type has run, so | |
| 230 | + // the contested check sees the sibling and the guard flushes once. | |
| 231 | + // WP_Rewrite::flush_rules() already defers itself to `wp_loaded`, so | |
| 232 | + // nothing is lost by deciding here, and did_action('wp_loaded') is | |
| 233 | + // truthy inside this callback, so the flush lands in time for | |
| 234 | + // parse_request in the same request. (#266 QA) | |
| 235 | + // Priority 0: still after every `init` callback, but ahead of the | |
| 236 | + // widely copied `add_action( 'wp_loaded', 'flush_rewrite_rules' )` | |
| 237 | + // snippet. If such a plugin flushed first it would write a table | |
| 238 | + // without our rules, our guard would find them missing and flush | |
| 239 | + // again -- two flushes and two option writes on every request. | |
| 240 | + add_action( 'wp_loaded', array( $this, 'add_rewrite' ), 0 ); | |
| 110 | 241 | add_filter( 'query_vars', array( $this, 'register_query_var' ) ); |
| 111 | - add_action( 'parse_request', array( $this, 'maybe_handle_pretty_endpoint' ) ); | |
| 242 | + // Priority 1: a sibling MCP plugin that also claims /.well-known/ gets | |
| 243 | + // to answer first at the default priority 10, and whoever answers | |
| 244 | + // first calls exit(). Running early means the URL is decided by WHOSE | |
| 245 | + // path it is, not by which plugin happened to load last. | |
| 246 | + add_action( 'parse_request', array( $this, 'maybe_handle_pretty_endpoint' ), 1 ); | |
| 247 | + | |
| 248 | + // Hub redirect-return: after the user approves on the Hub, it sends the | |
| 249 | + // browser back to a plugin admin URL carrying ?xspeed_connected=1 plus | |
| 250 | + // the account email + the SAME signed nonce we minted. We verify our own | |
| 251 | + // nonce and mark this admin attached — no server-to-server callback | |
| 252 | + // needed, so it works for local/firewalled sites too. | |
| 253 | + add_action( 'admin_init', array( $this, 'maybe_handle_hub_return' ) ); | |
| 254 | + // The Hub connect consent page (xspeed-hub#307). Priority 1: it emits a | |
| 255 | + // standalone page and exits before anything else on admin_init runs. | |
| 256 | + add_action( 'admin_init', array( Mcp_Hub_Connect::class, 'maybe_handle_page' ), 1 ); | |
| 257 | + add_action( 'admin_menu', array( Mcp_Hub_Connect::class, 'register_page' ) ); | |
| 258 | + | |
| 259 | + // An attached admin who is DELETED (or removed from the blog) never | |
| 260 | + // runs disconnect(), so the site-level attached mirror would report | |
| 261 | + // hub:true forever. deleted_user fires after both wp_delete_user() | |
| 262 | + // and wpmu_delete_user() drop the user, so a plain recompute is | |
| 263 | + // honest there. remove_user_from_blog is core's only removal action | |
| 264 | + // and fires BEFORE removal, so its handler clears the departing | |
| 265 | + // user's record before recomputing (see Mcp_Hub::handle_user_removed). | |
| 266 | + add_action( 'deleted_user', array( Mcp_Hub::class, 'refresh_site_attached' ) ); | |
| 267 | + add_action( 'remove_user_from_blog', array( Mcp_Hub::class, 'handle_user_removed' ) ); | |
| 112 | 268 | } |
| 113 | 269 | |
| 114 | 270 | /** |
| 271 | + * Handle the browser landing back from the Hub after a connect. Idempotent | |
| 272 | + * and safe to run on every admin page load: it only acts when the return | |
| 273 | + * markers are present and the nonce verifies. | |
| 274 | + */ | |
| 275 | + public function maybe_handle_hub_return(): void { | |
| 276 | + // phpcs:disable WordPress.Security.NonceVerification.Recommended -- auth is the signed HMAC nonce below, not a WP nonce; this is a read-only routing check. | |
| 277 | + $nonce = isset( $_GET['xspeed_hub_nonce'] ) ? sanitize_text_field( wp_unslash( $_GET['xspeed_hub_nonce'] ) ) : ''; | |
| 278 | + $email = isset( $_GET['xspeed_hub_email'] ) ? sanitize_email( wp_unslash( $_GET['xspeed_hub_email'] ) ) : ''; | |
| 279 | + | |
| 280 | + /* | |
| 281 | + * Trigger on the signed nonce, not on `xspeed_connected`. | |
| 282 | + * | |
| 283 | + * The Hub bounces the browser back with xspeed_hub_nonce + | |
| 284 | + * xspeed_hub_email, but it does NOT always append xspeed_connected — | |
| 285 | + * that marker only survives when the return_url we handed it carried | |
| 286 | + * one. Gating on it meant a real, correctly-signed return was ignored: | |
| 287 | + * the attach was never recorded, the params were never stripped, and | |
| 288 | + * the card kept showing "Not connected" while the nonce sat in the | |
| 289 | + * address bar. The nonce is the actual proof of a genuine round trip, | |
| 290 | + * so it is what this handler keys on. (FBS-84086) | |
| 291 | + */ | |
| 292 | + if ( '' === $nonce && empty( $_GET['xspeed_connected'] ) ) { | |
| 293 | + return; | |
| 294 | + } | |
| 295 | + // phpcs:enable WordPress.Security.NonceVerification.Recommended | |
| 296 | + | |
| 297 | + if ( ! current_user_can( 'manage_options' ) ) { | |
| 298 | + return; | |
| 299 | + } | |
| 300 | + | |
| 301 | + // Verify OUR own signed nonce (proves the round-trip went through the | |
| 302 | + // Hub with a token we minted), then record the connection. | |
| 303 | + // Signature only: the Hub has already spent this nonce on the attach | |
| 304 | + // callback, and recording the link needs no credential. | |
| 305 | + if ( '' !== $nonce ) { | |
| 306 | + $uid = Mcp_Hub::check_attach_nonce( $nonce ); | |
| 307 | + if ( null !== $uid ) { | |
| 308 | + Mcp_Hub::mark_attached( $email, $uid ?: null ); | |
| 309 | + } | |
| 310 | + } | |
| 311 | + | |
| 312 | + // ALWAYS strip the one-time return markers from the URL and redirect to | |
| 313 | + // the clean address. These params are single-use; if they persist in the | |
| 314 | + // browser URL, a later reload re-triggers the "just connected" path and | |
| 315 | + // flashes a stale connected state even after the user has disconnected. | |
| 316 | + $clean = remove_query_arg( array( 'xspeed_connected', 'xspeed_hub_nonce', 'xspeed_hub_email' ) ); | |
| 317 | + | |
| 318 | + // The setup wizard keeps its current step in component state, so a | |
| 319 | + // redirect remounts it at step 1 — dumping the user back at the START of | |
| 320 | + // onboarding immediately after they finished its LAST step. Carry a | |
| 321 | + // durable hint so the wizard resumes on Connect instead. It's a plain | |
| 322 | + // step marker, not an auth signal (the nonce above did that job), and | |
| 323 | + // it's safe to leave in the URL: re-loading it just re-opens the same | |
| 324 | + // step rather than re-running the connect path. (PM feedback) | |
| 325 | + if ( false !== strpos( (string) $clean, 'page=' . Onboarding::PAGE_SLUG ) ) { | |
| 326 | + $clean = add_query_arg( 'xspeed_step', 'connect', $clean ); | |
| 327 | + } | |
| 328 | + | |
| 329 | + wp_safe_redirect( $clean ); | |
| 330 | + exit; | |
| 331 | + } | |
| 332 | + | |
| 333 | + /** | |
| 115 | 334 | * Flush rewrites once when the module first boots so /xspeed/mcp works |
| 116 | 335 | * without a manual permalink re-save. Cheap: gated on a one-shot flag. |
| 117 | 336 | */ |
| 118 | 337 | public function activate(): void { |
| @@ -126,8 +345,15 @@ | ||
| 126 | 345 | |
| 127 | 346 | // -- Pretty endpoint: /xspeed/mcp -- |
| 128 | 347 | |
| 129 | 348 | public function add_rewrite(): void { |
| 349 | + // The stored table is WordPress's routing table AND the only durable | |
| 350 | + // record of which plugin owns which discovery URL, so both the | |
| 351 | + // conditional registration below and the self-heal guard at the | |
| 352 | + // bottom read the SAME snapshot of it. Deciding twice from two reads | |
| 353 | + // is how a guard ends up flushing away a rule it just registered. | |
| 354 | + $stored_rules = get_option( 'rewrite_rules' ); | |
| 355 | + | |
| 130 | 356 | // Token-in-URL form: /xspeed/mcp/<token> — a single string the user |
| 131 | 357 | // pastes into their AI client (no separate token field). The bare |
| 132 | 358 | // /xspeed/mcp still works with a Bearer/header token. |
| 133 | 359 | add_rewrite_rule( |
| @@ -142,50 +368,280 @@ | ||
| 142 | 368 | // own rewrite (consistent with the MCP URL, survives hosts that block |
| 143 | 369 | // /wp-json). Placed BEFORE the token rule would never match "attach" |
| 144 | 370 | // (that rule requires 64 hex chars), so ordering is safe. |
| 145 | 371 | add_rewrite_rule( '^xspeed/mcp/attach/?$', 'index.php?' . self::ATTACH_QUERY_VAR . '=1', 'top' ); |
| 372 | + // The Hub connect document, outside /wp-json for the same reason: a | |
| 373 | + // site whose security plugin refuses anonymous REST still answers it. | |
| 374 | + add_rewrite_rule( '^xspeed/mcp/connect-info/?$', 'index.php?' . self::CONNECT_INFO_QUERY_VAR . '=1', 'top' ); | |
| 146 | 375 | |
| 147 | 376 | // OAuth discovery documents. RFC 9728 §3.1 / RFC 8414 §3.1 place the |
| 148 | - // `.well-known` segment BEFORE the resource path, so a resource at | |
| 149 | - // /xspeed/mcp is discovered at BOTH: | |
| 150 | - // /.well-known/oauth-protected-resource (root form) | |
| 151 | - // /.well-known/oauth-protected-resource/xspeed/mcp (path-suffixed) | |
| 152 | - // Real clients (e.g. Claude Desktop) request the path-suffixed form; | |
| 153 | - // serving only the root form 404s them and the connection aborts. The | |
| 154 | - // optional `(?:/.*)?` tail matches both without caring about the exact | |
| 155 | - // resource path (we only serve one resource). | |
| 377 | + // `.well-known` segment BEFORE the resource/issuer path, and both of | |
| 378 | + // our canonical identifiers are the MCP endpoint URL, so our | |
| 379 | + // documents live at: | |
| 380 | + // /.well-known/oauth-protected-resource/xspeed/mcp | |
| 381 | + // /.well-known/oauth-authorization-server/xspeed/mcp | |
| 382 | + // | |
| 383 | + // Matched EXACTLY. A `(?:/.*)?` tail covers our URLs in one rule, but | |
| 384 | + // also matches every OTHER plugin's discovery URL on the same site — | |
| 385 | + // and WordPress matches rewrite rules in table order rather than by | |
| 386 | + // specificity, so a sibling's own exact rule never gets reached. Its | |
| 387 | + // clients then receive OUR metadata, find a resource and issuer that | |
| 388 | + // do not match what they are connecting to, and abort before the | |
| 389 | + // login screen. | |
| 156 | 390 | add_rewrite_rule( |
| 157 | - '^\.well-known/oauth-(protected-resource|authorization-server)(?:/.*)?/?$', | |
| 391 | + '^\\.well-known/oauth-(protected-resource|authorization-server)/xspeed/mcp/?$', | |
| 158 | 392 | 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]', |
| 159 | 393 | 'top' |
| 160 | 394 | ); |
| 161 | 395 | |
| 396 | + // The bare root form, ONLY while no other plugin claims it. A client | |
| 397 | + // written to the 2025-03-26 MCP spec looks there and nowhere else, so | |
| 398 | + // giving it up unconditionally would break those clients on every | |
| 399 | + // site — including the single-plugin sites where the collision never | |
| 400 | + // happened. When a sibling's rule is present the URL is theirs and we | |
| 401 | + // register nothing, which is the case #266 is about. Current clients | |
| 402 | + // read the protected-resource document first and follow it wherever | |
| 403 | + // it points, so they are unaffected either way. The document served | |
| 404 | + // at root carries the LEGACY | |
| 405 | + // host-only issuer, because that is the identifier a client used to | |
| 406 | + // derive that URL (RFC 8414 §3.3). | |
| 407 | + $root_contested = self::root_discovery_contested( $stored_rules ); | |
| 408 | + if ( ! $root_contested ) { | |
| 409 | + add_rewrite_rule( | |
| 410 | + self::ROOT_DISCOVERY_RULE, | |
| 411 | + 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]', | |
| 412 | + 'top' | |
| 413 | + ); | |
| 414 | + } | |
| 415 | + | |
| 162 | 416 | // Browser-facing OAuth consent page — served OUTSIDE REST so cookie |
| 163 | 417 | // auth (is_user_logged_in) works after the wp-login round-trip. |
| 164 | 418 | add_rewrite_rule( '^xspeed/authorize/?$', 'index.php?' . self::AUTHORIZE_QUERY_VAR . '=1', 'top' ); |
| 165 | 419 | |
| 166 | - // Self-heal: flush once if ANY of our rules is missing from the stored | |
| 167 | - // rewrite table. Checking only the first rule is not enough — a site | |
| 168 | - // flushed under an older build (which had /xspeed/mcp but not the | |
| 169 | - // later /xspeed/authorize + /.well-known rules) keeps that first rule, | |
| 170 | - // so the guard never fires and OAuth discovery 404s forever. Guard on | |
| 171 | - // the full set so any newly-added rule triggers a re-flush. | |
| 172 | - $expected = array( | |
| 173 | - '^xspeed/mcp/([a-f0-9]{64})/?$', | |
| 174 | - '^xspeed/mcp/?$', | |
| 175 | - '^xspeed/mcp/attach/?$', | |
| 176 | - '^\.well-known/oauth-(protected-resource|authorization-server)(?:/.*)?/?$', | |
| 177 | - '^xspeed/authorize/?$', | |
| 178 | - ); | |
| 420 | + // Self-heal: flush once if the stored rewrite table disagrees with the | |
| 421 | + // rules we just registered. Checking only the first rule is not | |
| 422 | + // enough — a site flushed under an older build (which had /xspeed/mcp | |
| 423 | + // but not the later /xspeed/authorize + /.well-known rules) keeps that | |
| 424 | + // first rule, so the guard never fires and OAuth discovery 404s | |
| 425 | + // forever. Guard on the full set so any newly-added rule triggers a | |
| 426 | + // re-flush. | |
| 427 | + // | |
| 428 | + // The guard must mirror the registration decisions EXACTLY, or it | |
| 429 | + // never reaches a fixed point: | |
| 430 | + // | |
| 431 | + // - Uncontested root: we register it, so the table must hold it | |
| 432 | + // with OUR target. A flush produces exactly that, and the next | |
| 433 | + // request reads the same table and stays uncontested — our own | |
| 434 | + // target never counts as a sibling's. | |
| 435 | + // - Contested root: we register nothing, so OUR copy must be gone. | |
| 436 | + // A flush regenerates the sibling's rule (they register it every | |
| 437 | + // request) but not ours, so the next request is quiet. | |
| 438 | + // | |
| 439 | + // Ownership is read from the TARGET in both directions. Keying on the | |
| 440 | + // regex alone is what produced a flush on every request forever when | |
| 441 | + // a sibling held that regex: their rule comes back from every flush. | |
| 442 | + // | |
| 443 | + // This runs on `wp_loaded` for every request, so the first request after an | |
| 444 | + // upgrade flushes once and the guard is quiet from then on. It cannot | |
| 445 | + // move to Plugin::maybe_upgrade() — that is admin-only and runs at | |
| 446 | + // plugins_loaded 21, i.e. BEFORE init, so a flush there would write a | |
| 447 | + // table without our rules and this guard would flush a second time. | |
| 448 | + if ( ! is_array( $stored_rules ) ) { | |
| 449 | + return; | |
| 450 | + } | |
| 451 | + | |
| 452 | + $stale = false; | |
| 453 | + $retiring = false; | |
| 454 | + foreach ( self::REWRITE_RULES as $rule ) { | |
| 455 | + if ( ! isset( $stored_rules[ $rule ] ) ) { | |
| 456 | + $stale = true; | |
| 457 | + break; | |
| 458 | + } | |
| 459 | + } | |
| 460 | + | |
| 461 | + // Our copy of the root rule must be present exactly when we register | |
| 462 | + // it. Present-and-unwanted is the #266 upgrade; absent-and-wanted is | |
| 463 | + // an older table, or a sibling that has since gone away. | |
| 464 | + $root_is_ours = isset( $stored_rules[ self::ROOT_DISCOVERY_RULE ] ) | |
| 465 | + && self::is_our_rule_target( $stored_rules[ self::ROOT_DISCOVERY_RULE ] ); | |
| 466 | + if ( $root_is_ours === $root_contested ) { | |
| 467 | + $stale = true; | |
| 468 | + $retiring = $root_contested; | |
| 469 | + } | |
| 470 | + | |
| 471 | + // Not an identity move — nothing a site owner can act on — so this | |
| 472 | + // one flushes quietly. | |
| 473 | + foreach ( self::RETIRED_REWRITE_RULES as $rule ) { | |
| 474 | + if ( isset( $stored_rules[ $rule ] ) && self::is_our_rule_target( $stored_rules[ $rule ] ) ) { | |
| 475 | + $stale = true; | |
| 476 | + break; | |
| 477 | + } | |
| 478 | + } | |
| 479 | + | |
| 480 | + if ( ! $stale ) { | |
| 481 | + return; | |
| 482 | + } | |
| 483 | + | |
| 484 | + if ( $retiring ) { | |
| 485 | + // The one moment the identity move is observable to a site owner, | |
| 486 | + // and it happens on a front-end request with no UI attached. Fires | |
| 487 | + // once: after the flush our rule is gone, so the next request | |
| 488 | + // finds nothing to hand over. | |
| 489 | + Activity_Log::record( | |
| 490 | + 'mcp_discovery_moved', | |
| 491 | + __( 'Another plugin now claims the site-wide OAuth discovery address, so xSpeed handed it over. Its own is /.well-known/oauth-protected-resource/xspeed/mcp — AI assistants already connected may ask for approval once more.', 'xspeed' ), | |
| 492 | + Activity_Log::INFO | |
| 493 | + ); | |
| 494 | + } | |
| 495 | + | |
| 496 | + flush_rewrite_rules( false ); | |
| 497 | + } | |
| 498 | + | |
| 499 | + /** | |
| 500 | + * Whether another plugin's rewrite rule already routes the ROOT discovery | |
| 501 | + * URLs, making them theirs rather than ours. | |
| 502 | + * | |
| 503 | + * Read from two views of the rewrite table, because neither alone is | |
| 504 | + * complete at `init`: | |
| 505 | + * | |
| 506 | + * - the STORED table, which is what WordPress actually routes with and | |
| 507 | + * the only view that survives the request. If a sibling registered | |
| 508 | + * the same regex after us at the last flush, its target is what is | |
| 509 | + * stored, and that is precisely "the sibling owns this URL now". | |
| 510 | + * - the IN-MEMORY rules registered so far this request, which catches a | |
| 511 | + * sibling that hooks `init` earlier than we do and has therefore not | |
| 512 | + * reached the stored table yet. | |
| 513 | + * | |
| 514 | + * A sibling that registers LATER than us used to be the one case neither | |
| 515 | + * view saw, and it did NOT resolve itself: the guard is what triggers a | |
| 516 | + * flush, so a guard reading an incomplete view simply never fires. That | |
| 517 | + * is why add_rewrite() now runs on `wp_loaded` rather than `init` — by | |
| 518 | + * then every plugin has registered, whatever its load order. | |
| 519 | + * | |
| 520 | + * @param mixed $stored The stored rewrite table, if already read. | |
| 521 | + */ | |
| 522 | + public static function root_discovery_contested( $stored = null ): bool { | |
| 523 | + $tables = array(); | |
| 524 | + if ( is_array( $stored ) ) { | |
| 525 | + $tables[] = $stored; | |
| 526 | + } elseif ( null === $stored ) { | |
| 527 | + $option = get_option( 'rewrite_rules' ); | |
| 528 | + if ( is_array( $option ) ) { | |
| 529 | + $tables[] = $option; | |
| 530 | + } | |
| 531 | + } | |
| 532 | + | |
| 533 | + if ( isset( $GLOBALS['wp_rewrite'] ) && is_object( $GLOBALS['wp_rewrite'] ) ) { | |
| 534 | + foreach ( array( 'extra_rules_top', 'extra_rules' ) as $prop ) { | |
| 535 | + if ( isset( $GLOBALS['wp_rewrite']->$prop ) && is_array( $GLOBALS['wp_rewrite']->$prop ) ) { | |
| 536 | + $tables[] = $GLOBALS['wp_rewrite']->$prop; | |
| 537 | + } | |
| 538 | + } | |
| 539 | + } | |
| 540 | + | |
| 541 | + foreach ( $tables as $rules ) { | |
| 542 | + foreach ( $rules as $pattern => $target ) { | |
| 543 | + if ( ! self::is_a_wellknown_rule( (string) $pattern ) || self::is_our_rule_target( $target ) ) { | |
| 544 | + continue; | |
| 545 | + } | |
| 546 | + foreach ( array( 'protected-resource', 'authorization-server' ) as $doc ) { | |
| 547 | + $probe = '.well-known/oauth-' . $doc; | |
| 548 | + if ( preg_match( '#' . str_replace( '#', '\\#', (string) $pattern ) . '#', $probe ) ) { | |
| 549 | + return true; | |
| 550 | + } | |
| 551 | + } | |
| 552 | + } | |
| 553 | + } | |
| 554 | + | |
| 555 | + return false; | |
| 556 | + } | |
| 557 | + | |
| 558 | + /** | |
| 559 | + * Whether a rewrite regex was written FOR a .well-known discovery URL, | |
| 560 | + * as opposed to merely matching one. | |
| 561 | + * | |
| 562 | + * WordPress's own page rule -- `(.?.+?)/?$` => `index.php?pagename=...` | |
| 563 | + * -- is in the stored table of every site using pretty permalinks, and | |
| 564 | + * it matches `.well-known/oauth-protected-resource` exactly as it | |
| 565 | + * matches every other path on the site. Reading that as a sibling's | |
| 566 | + * claim would report root as contested EVERYWHERE: the root document | |
| 567 | + * would be retired on every install, including the single-plugin sites | |
| 568 | + * this change exists to leave alone, and each of them would log a | |
| 569 | + * hand-over that never happened. | |
| 570 | + * | |
| 571 | + * A rule that routes these URLs on purpose spells the segment out, so | |
| 572 | + * that is the signal. Backslashes are stripped first because the regex | |
| 573 | + * carries them as escapes (`^\\.well-known/...`) and a rule is free to | |
| 574 | + * escape the hyphen too. | |
| 575 | + * | |
| 576 | + * @param string $pattern The stored rewrite regex. | |
| 577 | + */ | |
| 578 | + private static function is_a_wellknown_rule( string $pattern ): bool { | |
| 579 | + return false !== stripos( str_replace( '\\', '', $pattern ), 'well-known' ); | |
| 580 | + } | |
| 581 | + | |
| 582 | + /** | |
| 583 | + * Whether a stored rewrite target was written by this module. | |
| 584 | + * | |
| 585 | + * Every rule we register resolves to `index.php?<one of our query | |
| 586 | + * vars>=…`, and no other plugin sets those. Used to tell OUR leftover | |
| 587 | + * copy of a retired rule from a sibling's rule that happens to share the | |
| 588 | + * regex — only the first is ours to flush away. | |
| 589 | + * | |
| 590 | + * @param mixed $target The stored rewrite target. | |
| 591 | + */ | |
| 592 | + private static function is_our_rule_target( $target ): bool { | |
| 593 | + if ( ! is_string( $target ) ) { | |
| 594 | + return false; | |
| 595 | + } | |
| 596 | + | |
| 597 | + foreach ( array( self::QUERY_VAR, self::TOKEN_QUERY_VAR, self::WELLKNOWN_QUERY_VAR, self::AUTHORIZE_QUERY_VAR, self::ATTACH_QUERY_VAR, self::CONNECT_INFO_QUERY_VAR ) as $var ) { | |
| 598 | + if ( false !== strpos( $target, $var . '=' ) ) { | |
| 599 | + return true; | |
| 600 | + } | |
| 601 | + } | |
| 602 | + | |
| 603 | + return false; | |
| 604 | + } | |
| 605 | + | |
| 606 | + /** | |
| 607 | + * True when a request for our discovery URL can reach WordPress at all. | |
| 608 | + * | |
| 609 | + * Since maybe_handle_pretty_endpoint() claims the document by REQUEST | |
| 610 | + * PATH, a sibling plugin winning the rewrite match no longer matters — | |
| 611 | + * we answer either way. What still breaks the pretty URL is there being | |
| 612 | + * no rewrite for it in the first place (plain permalinks), because then | |
| 613 | + * nothing routes the path to index.php and parse_request never runs. | |
| 614 | + * | |
| 615 | + * Blind to upstream interception: a host that owns the /.well-known/ | |
| 616 | + * prefix (an nginx ACME block, an edge redirect rule) answers before | |
| 617 | + * WordPress loads, and WP cannot see that. Use the | |
| 618 | + * `xspeed_mcp_resource_metadata_url` filter on such hosts. | |
| 619 | + */ | |
| 620 | + public static function wellknown_rewrites_active(): bool { | |
| 179 | 621 | $rules = get_option( 'rewrite_rules' ); |
| 180 | - if ( is_array( $rules ) ) { | |
| 181 | - foreach ( $expected as $rule ) { | |
| 182 | - if ( ! isset( $rules[ $rule ] ) ) { | |
| 183 | - flush_rewrite_rules( false ); | |
| 184 | - break; | |
| 185 | - } | |
| 622 | + if ( ! is_array( $rules ) || array() === $rules ) { | |
| 623 | + return false; | |
| 624 | + } | |
| 625 | + | |
| 626 | + // Any rule that routes our discovery path to index.php will do — ours | |
| 627 | + // or a sibling's — because the path check inside the handler decides | |
| 628 | + // the outcome once the request lands. | |
| 629 | + // | |
| 630 | + // Probe the URL the 401 challenge actually advertises: the | |
| 631 | + // path-suffixed form, which is the canonical identity since #266. | |
| 632 | + // Probing root would answer a different question — whether ANY plugin | |
| 633 | + // routes the contested URL — and on a site where a sibling owns it | |
| 634 | + // that answer says nothing about whether our own document is | |
| 635 | + // reachable. | |
| 636 | + $probe = '.well-known/oauth-protected-resource/' . Mcp_Pairing::SITE_ENDPOINT_PATH; | |
| 637 | + foreach ( $rules as $pattern => $target ) { | |
| 638 | + if ( preg_match( '#' . str_replace( '#', '\\#', $pattern ) . '#', $probe ) ) { | |
| 639 | + return true; | |
| 186 | 640 | } |
| 187 | 641 | } |
| 642 | + | |
| 643 | + return false; | |
| 188 | 644 | } |
| 189 | 645 | |
| 190 | 646 | /** |
| 191 | 647 | * @param string[] $vars Registered query vars. |
| @@ -196,12 +652,115 @@ | ||
| 196 | 652 | $vars[] = self::TOKEN_QUERY_VAR; |
| 197 | 653 | $vars[] = self::WELLKNOWN_QUERY_VAR; |
| 198 | 654 | $vars[] = self::AUTHORIZE_QUERY_VAR; |
| 199 | 655 | $vars[] = self::ATTACH_QUERY_VAR; |
| 656 | + $vars[] = self::CONNECT_INFO_QUERY_VAR; | |
| 200 | 657 | return $vars; |
| 201 | 658 | } |
| 202 | 659 | |
| 203 | 660 | /** |
| 661 | + * Which discovery document the CURRENT request path asks for, if any. | |
| 662 | + * | |
| 663 | + * Claims only URLs that are unambiguously ours, mirroring the rewrite | |
| 664 | + * rules exactly: the RFC 9728 §3.1 / RFC 8414 §3.1 path-suffixed form | |
| 665 | + * naming our own resource and issuer (`/xspeed/mcp`). A suffix belonging | |
| 666 | + * to a sibling plugin is deliberately NOT claimed — answering | |
| 667 | + * `/.well-known/oauth-protected-resource/betterlinks/mcp` with xSpeed | |
| 668 | + * metadata is the same bug that broke this site, just pointed the other | |
| 669 | + * way. | |
| 670 | + * | |
| 671 | + * The bare root form is claimed only while no other plugin's rewrite rule | |
| 672 | + * claims it. Leaving the suffix optional here took the root document from | |
| 673 | + * a sibling even on a build that had stopped registering its own root | |
| 674 | + * rule, so the path check is exact. | |
| 675 | + * | |
| 676 | + * The TABLE is what hands root over -- add_rewrite() stops registering | |
| 677 | + * the rule and flushes it away. This claim only releases it, and only | |
| 678 | + * while a sibling's rule actually owns the URL: once WordPress has | |
| 679 | + * routed the request to OUR query var, no other plugin's handler can see | |
| 680 | + * it, so releasing it would abandon the request to the front page rather | |
| 681 | + * than pass it on. Note that is about the winning rule's TARGET, not its | |
| 682 | + * regex -- a sibling can hold the same pattern. The document served at root carries the LEGACY host-only | |
| 683 | + * issuer, the identifier a client used to derive that URL. (#266) | |
| 684 | + * | |
| 685 | + * @param string $matched_query The query the matched rule resolved to. | |
| 686 | + * @return array{doc:string,issuer:string} Doc name ('' when not ours) | |
| 687 | + * and the identity to stamp on it. | |
| 688 | + */ | |
| 689 | + private function wellknown_claim_from_path( string $matched_query = '' ): array { | |
| 690 | + $none = array( | |
| 691 | + 'doc' => '', | |
| 692 | + 'issuer' => '', | |
| 693 | + ); | |
| 694 | + | |
| 695 | + $uri = isset( $_SERVER['REQUEST_URI'] ) | |
| 696 | + ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) | |
| 697 | + : ''; | |
| 698 | + if ( '' === $uri ) { | |
| 699 | + return $none; | |
| 700 | + } | |
| 701 | + | |
| 702 | + $path = (string) wp_parse_url( $uri, PHP_URL_PATH ); | |
| 703 | + | |
| 704 | + // Sites in a subdirectory carry that prefix on every request. | |
| 705 | + $home = (string) wp_parse_url( home_url(), PHP_URL_PATH ); | |
| 706 | + if ( '' !== $home && '/' !== $home && 0 === strpos( $path, $home ) ) { | |
| 707 | + $path = substr( $path, strlen( $home ) ); | |
| 708 | + } | |
| 709 | + | |
| 710 | + $path = trim( $path, '/' ); | |
| 711 | + | |
| 712 | + // trim() above already dropped a trailing slash, so `/xspeed/mcp/` | |
| 713 | + // still matches — and so does the root form with one. | |
| 714 | + $ours = '#^\.well-known/oauth-(protected-resource|authorization-server)' | |
| 715 | + . '/' . preg_quote( Mcp_Pairing::SITE_ENDPOINT_PATH, '#' ) . '$#'; | |
| 716 | + if ( preg_match( $ours, $path, $m ) ) { | |
| 717 | + return array( | |
| 718 | + 'doc' => $m[1], | |
| 719 | + 'issuer' => Mcp_OAuth::issuer(), | |
| 720 | + ); | |
| 721 | + } | |
| 722 | + | |
| 723 | + // Releasing root is only safe when somebody else can pick it up. If | |
| 724 | + // OUR rule is what WordPress matched, nobody can: the sibling's query | |
| 725 | + // var is unset, so its handler never runs, and the request falls | |
| 726 | + // through to the front page -- a 301 to the homepage where dev | |
| 727 | + // returns JSON. Answering with the legacy document is the pre-#266 | |
| 728 | + // behaviour. (#266 QA) | |
| 729 | + // | |
| 730 | + // Keyed on the query the matched rule RESOLVED TO -- not on | |
| 731 | + // $wp->query_vars, and not on which regex matched. | |
| 732 | + // | |
| 733 | + // query_vars is wrong because the var is public and WP::parse_request | |
| 734 | + // lets $_GET override anything a rule set, so `?xspeed_mcp_wellknown=1` | |
| 735 | + // would let anyone force our metadata onto a URL a sibling owns. | |
| 736 | + // | |
| 737 | + // matched_rule is wrong because the rewrite table is keyed BY regex: | |
| 738 | + // a sibling that registered this same pattern replaces our entry and | |
| 739 | + // the key still reads as ours, while the target behind it is theirs. | |
| 740 | + // That is a live case here -- is_our_rule_target() exists for it -- | |
| 741 | + // and keying on the rule would answer for the sibling, which is the | |
| 742 | + // bug this whole change is about. | |
| 743 | + // | |
| 744 | + // matched_query is built from the winning rule's TARGET (class-wp.php, | |
| 745 | + // before the parse_request action) and $_GET never touches it. If it | |
| 746 | + // sets our query var, our rule genuinely won. (#266 QA) | |
| 747 | + $routed_to_us = 1 === preg_match( | |
| 748 | + '#(?:^|&)' . preg_quote( self::WELLKNOWN_QUERY_VAR, '#' ) . '=#', | |
| 749 | + $matched_query | |
| 750 | + ); | |
| 751 | + $root = '#^\.well-known/oauth-(protected-resource|authorization-server)$#'; | |
| 752 | + if ( preg_match( $root, $path, $m ) && ( $routed_to_us || ! self::root_discovery_contested() ) ) { | |
| 753 | + return array( | |
| 754 | + 'doc' => $m[1], | |
| 755 | + 'issuer' => Mcp_OAuth::legacy_issuer(), | |
| 756 | + ); | |
| 757 | + } | |
| 758 | + | |
| 759 | + return $none; | |
| 760 | + } | |
| 761 | + | |
| 762 | + /** | |
| 204 | 763 | * Serve the MCP endpoint on the pretty path. Runs on parse_request so |
| 205 | 764 | * it fires before the main query, and short-circuits WP entirely. |
| 206 | 765 | * |
| 207 | 766 | * @param \WP $wp The WP request object. |
| @@ -206,29 +765,66 @@ | ||
| 206 | 765 | * |
| 207 | 766 | * @param \WP $wp The WP request object. |
| 208 | 767 | */ |
| 209 | 768 | public function maybe_handle_pretty_endpoint( $wp ): void { |
| 210 | - // OAuth discovery documents (served at the site root). | |
| 211 | - if ( ! empty( $wp->query_vars[ self::WELLKNOWN_QUERY_VAR ] ) ) { | |
| 212 | - $doc = (string) $wp->query_vars[ self::WELLKNOWN_QUERY_VAR ]; | |
| 769 | + // OAuth discovery documents. | |
| 770 | + // | |
| 771 | + // Read the doc name from the REQUEST PATH, and ONLY from the path. | |
| 772 | + // `add_rewrite_rule( …, 'top' )` only means "top at the moment it | |
| 773 | + // runs", so whichever MCP plugin hooks `init` last ends up first in | |
| 774 | + // the table — an order set by plugin load order, which no plugin | |
| 775 | + // controls. A sibling's catch-all | |
| 776 | + // (`…(protected-resource|authorization-server)(?:/.*)?/?$`) then wins | |
| 777 | + // the match and our query var is never set, even though the URL is | |
| 778 | + // unambiguously ours. Observed live with two different plugins on one | |
| 779 | + // site. parse_request runs AFTER matching, so the path is the one | |
| 780 | + // signal no sibling rule can take away from us. | |
| 781 | + // | |
| 782 | + // The query VAR is deliberately never consulted. It is public, so | |
| 783 | + // $_GET can set it on any URL, and answering from it would put our | |
| 784 | + // metadata on somebody else's address — the whole bug. Which rewrite | |
| 785 | + // RULE matched is a different thing: WordPress decides it, the query | |
| 786 | + // string cannot influence it, and it is only read to tell "a sibling | |
| 787 | + // owns this URL" from "we own it and nobody else can answer". | |
| 788 | + $matched_query = is_object( $wp ) && isset( $wp->matched_query ) ? (string) $wp->matched_query : ''; | |
| 789 | + $claim = $this->wellknown_claim_from_path( $matched_query ); | |
| 790 | + $doc = $claim['doc']; | |
| 791 | + if ( '' !== $doc ) { | |
| 213 | 792 | $data = 'authorization-server' === $doc |
| 214 | - ? Mcp_OAuth::authorization_server_metadata() | |
| 215 | - : Mcp_OAuth::protected_resource_metadata(); | |
| 793 | + ? Mcp_OAuth::authorization_server_metadata( $claim['issuer'] ) | |
| 794 | + : Mcp_OAuth::protected_resource_metadata( $claim['issuer'] ); | |
| 216 | 795 | status_header( 200 ); |
| 217 | 796 | header( 'Content-Type: application/json; charset=utf-8' ); |
| 218 | - // Discovery metadata is public + cacheable. | |
| 219 | - header( 'Cache-Control: public, max-age=3600' ); | |
| 797 | + // Public and cacheable, but short: this document IS the server's | |
| 798 | + // identity, and a cached copy outliving an issuer change is the | |
| 799 | + // one failure a client cannot recover from on its own. (#266) | |
| 800 | + header( 'Cache-Control: public, max-age=300' ); | |
| 220 | 801 | echo wp_json_encode( $data ); |
| 221 | 802 | exit; |
| 222 | 803 | } |
| 223 | 804 | |
| 805 | + // Pretty Hub connect document: /xspeed/mcp/connect-info (xspeed-hub#307). | |
| 806 | + if ( ! empty( $wp->query_vars[ self::CONNECT_INFO_QUERY_VAR ] ) ) { | |
| 807 | + header( 'Content-Type: application/json; charset=utf-8' ); | |
| 808 | + header( 'Cache-Control: no-store' ); | |
| 809 | + status_header( 200 ); | |
| 810 | + echo wp_json_encode( Mcp_Hub_Connect::connect_info() ); | |
| 811 | + exit; | |
| 812 | + } | |
| 813 | + | |
| 224 | 814 | // Pretty attach-callback: /xspeed/mcp/attach. The hub POSTs the signed |
| 225 | 815 | // nonce; we verify it and return this site's URL + token. Auth is the |
| 226 | 816 | // nonce itself (admin-minted, HMAC-signed), so no credential needed. |
| 227 | 817 | if ( ! empty( $wp->query_vars[ self::ATTACH_QUERY_VAR ] ) ) { |
| 228 | - $body = json_decode( (string) file_get_contents( 'php://input' ), true ); | |
| 229 | - $nonce = is_array( $body ) && isset( $body['nonce'] ) ? (string) $body['nonce'] : ''; | |
| 230 | - $result = Mcp_Hub::verify_attach_nonce( $nonce ); | |
| 818 | + $body = json_decode( (string) file_get_contents( 'php://input' ), true ); | |
| 819 | + $body = is_array( $body ) ? $body : array(); | |
| 820 | + $field = static fn( string $k ): string => isset( $body[ $k ] ) && is_string( $body[ $k ] ) ? $body[ $k ] : ''; | |
| 821 | + $result = self::attach_result( $field( 'nonce' ), $field( 'code' ), $field( 'code_verifier' ) ); | |
| 822 | + // A Hub-started connect lands the browser on the Hub, not here, so | |
| 823 | + // this call is the only place the per-user link can be recorded. | |
| 824 | + if ( null !== $result && '' !== $field( 'code' ) ) { | |
| 825 | + Mcp_Hub::mark_attached( sanitize_email( $field( 'account_email' ) ), (int) $result['user_id'] ?: null ); | |
| 826 | + } | |
| 231 | 827 | header( 'Content-Type: application/json; charset=utf-8' ); |
| 232 | 828 | header( 'Cache-Control: no-store' ); |
| 233 | 829 | if ( null === $result ) { |
| 234 | 830 | status_header( 403 ); |
| @@ -233,8 +829,10 @@ | ||
| 233 | 829 | if ( null === $result ) { |
| 234 | 830 | status_header( 403 ); |
| 235 | 831 | echo wp_json_encode( array( 'error' => 'invalid_or_expired_attach_request' ) ); |
| 236 | 832 | } else { |
| 833 | + // The Hub needs only the credential, as on the REST route. | |
| 834 | + unset( $result['user_id'] ); | |
| 237 | 835 | status_header( 200 ); |
| 238 | 836 | echo wp_json_encode( $result ); |
| 239 | 837 | } |
| 240 | 838 | exit; |
| @@ -290,8 +888,26 @@ | ||
| 290 | 888 | 'permission_callback' => '__return_true', |
| 291 | 889 | ) |
| 292 | 890 | ); |
| 293 | 891 | |
| 892 | + // --- Public scan signals ----------------------------------------- | |
| 893 | + // One tiny unauthenticated JSON body for external audit tools (the | |
| 894 | + // speed scanner on xspeedcache.com): plugin version, whether the MCP | |
| 895 | + // server is connected, and whether the site is attached to xSpeed | |
| 896 | + // Hub. Everything except `hub` is already publicly discoverable — | |
| 897 | + // the cache signature carries the version and /mcp answers 401 when | |
| 898 | + // connected — and `hub` is a bare boolean. No tokens, accounts or | |
| 899 | + // emails leave through this route. | |
| 900 | + register_rest_route( | |
| 901 | + self::NS, | |
| 902 | + '/signals', | |
| 903 | + array( | |
| 904 | + 'methods' => 'GET', | |
| 905 | + 'callback' => array( $this, 'rest_signals' ), | |
| 906 | + 'permission_callback' => '__return_true', | |
| 907 | + ) | |
| 908 | + ); | |
| 909 | + | |
| 294 | 910 | // --- Admin-only management routes (dashboard) -------------------- |
| 295 | 911 | register_rest_route( |
| 296 | 912 | self::NS, |
| 297 | 913 | '/mcp/connection', |
| @@ -302,8 +918,34 @@ | ||
| 302 | 918 | ) |
| 303 | 919 | ); |
| 304 | 920 | register_rest_route( |
| 305 | 921 | self::NS, |
| 922 | + '/mcp/activity', | |
| 923 | + array( | |
| 924 | + 'methods' => 'GET', | |
| 925 | + 'callback' => array( $this, 'rest_activity' ), | |
| 926 | + 'permission_callback' => array( $this, 'admin_permission' ), | |
| 927 | + 'args' => array( | |
| 928 | + 'limit' => array( | |
| 929 | + 'type' => 'integer', | |
| 930 | + 'required' => false, | |
| 931 | + 'default' => 50, | |
| 932 | + 'description' => 'Maximum entries to return (newest first).', | |
| 933 | + ), | |
| 934 | + ), | |
| 935 | + ) | |
| 936 | + ); | |
| 937 | + register_rest_route( | |
| 938 | + self::NS, | |
| 939 | + '/mcp/activity/clear', | |
| 940 | + array( | |
| 941 | + 'methods' => 'POST', | |
| 942 | + 'callback' => array( $this, 'rest_activity_clear' ), | |
| 943 | + 'permission_callback' => array( $this, 'admin_permission' ), | |
| 944 | + ) | |
| 945 | + ); | |
| 946 | + register_rest_route( | |
| 947 | + self::NS, | |
| 306 | 948 | '/mcp/connect', |
| 307 | 949 | array( |
| 308 | 950 | 'methods' => 'POST', |
| 309 | 951 | 'callback' => array( $this, 'rest_connect' ), |
| @@ -401,10 +1043,28 @@ | ||
| 401 | 1043 | array( |
| 402 | 1044 | 'methods' => 'POST', |
| 403 | 1045 | 'callback' => array( $this, 'rest_hub_disconnect' ), |
| 404 | 1046 | 'permission_callback' => array( $this, 'admin_permission' ), |
| 1047 | + 'args' => array( | |
| 1048 | + 'acknowledge' => array( | |
| 1049 | + 'type' => 'boolean', | |
| 1050 | + 'default' => false, | |
| 1051 | + 'description' => 'Continue even though Mcp_Hub::disconnect_blockers() reported a reason not to. The caller has shown those reasons to a human.', | |
| 1052 | + ), | |
| 1053 | + ), | |
| 405 | 1054 | ) |
| 406 | 1055 | ); |
| 1056 | + // Hub connect discovery (xspeed-hub#307): public, so the Hub can ask | |
| 1057 | + // before sending anyone to the consent page. Names no secret. | |
| 1058 | + register_rest_route( | |
| 1059 | + self::NS, | |
| 1060 | + '/hub/connect-info', | |
| 1061 | + array( | |
| 1062 | + 'methods' => 'GET', | |
| 1063 | + 'callback' => array( $this, 'rest_hub_connect_info' ), | |
| 1064 | + 'permission_callback' => '__return_true', | |
| 1065 | + ) | |
| 1066 | + ); | |
| 407 | 1067 | // OAuth-attach callback: the hub calls this with the signed nonce the |
| 408 | 1068 | // plugin issued. Auth is the nonce itself (no pre-shared token), so |
| 409 | 1069 | // permission_callback is open — the handler validates the nonce. |
| 410 | 1070 | register_rest_route( |
| @@ -414,13 +1074,25 @@ | ||
| 414 | 1074 | 'methods' => 'POST', |
| 415 | 1075 | 'callback' => array( $this, 'rest_hub_attach_callback' ), |
| 416 | 1076 | 'permission_callback' => '__return_true', |
| 417 | 1077 | 'args' => array( |
| 418 | - 'nonce' => array( | |
| 1078 | + // One of: the signed nonce (plugin-started attach), or a | |
| 1079 | + // code plus its PKCE verifier (Hub-started connect, #307). | |
| 1080 | + 'nonce' => array( | |
| 419 | 1081 | 'type' => 'string', |
| 420 | - 'required' => true, | |
| 1082 | + 'required' => false, | |
| 421 | 1083 | 'description' => 'The signed attach nonce the plugin issued.', |
| 422 | 1084 | ), |
| 1085 | + 'code' => array( | |
| 1086 | + 'type' => 'string', | |
| 1087 | + 'required' => false, | |
| 1088 | + 'description' => 'The single-use code from the Hub connect consent page.', | |
| 1089 | + ), | |
| 1090 | + 'code_verifier' => array( | |
| 1091 | + 'type' => 'string', | |
| 1092 | + 'required' => false, | |
| 1093 | + 'description' => 'The PKCE verifier for that code.', | |
| 1094 | + ), | |
| 423 | 1095 | ), |
| 424 | 1096 | ) |
| 425 | 1097 | ); |
| 426 | 1098 | |
| @@ -450,13 +1122,49 @@ | ||
| 450 | 1122 | 'permission_callback' => '__return_true', |
| 451 | 1123 | ) |
| 452 | 1124 | ); |
| 453 | 1125 | |
| 1126 | + // --- OAuth discovery, REST fallback ------------------------------ | |
| 1127 | + // The canonical documents live at /.well-known/… via rewrite rules. | |
| 1128 | + // Many hosts own that prefix for ACME/Let's Encrypt (an nginx | |
| 1129 | + // `location ^~ /.well-known` block, or an edge redirect rule), which | |
| 1130 | + // swallows the request before WordPress ever runs — the pretty URL | |
| 1131 | + // then 404s or redirects to the homepage no matter how the plugin is | |
| 1132 | + // configured, and OAuth discovery dead-ends with no way back. | |
| 1133 | + // Serving the same two documents under /wp-json puts them on a path | |
| 1134 | + // no ACME tooling claims, so discovery still completes there. | |
| 1135 | + register_rest_route( | |
| 1136 | + self::NS, | |
| 1137 | + '/mcp/.well-known/oauth-protected-resource', | |
| 1138 | + array( | |
| 1139 | + 'methods' => 'GET', | |
| 1140 | + 'callback' => array( $this, 'rest_protected_resource_metadata' ), | |
| 1141 | + 'permission_callback' => '__return_true', | |
| 1142 | + ) | |
| 1143 | + ); | |
| 1144 | + register_rest_route( | |
| 1145 | + self::NS, | |
| 1146 | + '/mcp/.well-known/oauth-authorization-server', | |
| 1147 | + array( | |
| 1148 | + 'methods' => 'GET', | |
| 1149 | + 'callback' => array( $this, 'rest_authorization_server_metadata' ), | |
| 1150 | + 'permission_callback' => '__return_true', | |
| 1151 | + ) | |
| 1152 | + ); | |
| 1153 | + | |
| 454 | 1154 | // --- MCP-token-only tool routes (optional hosted-broker path) ---- |
| 455 | 1155 | $tool_perm = array( Mcp_Auth::class, 'permission' ); |
| 456 | 1156 | register_rest_route( |
| 457 | 1157 | self::NS, |
| 458 | - '/mcp/tool/(?P<tool>[a-z_]+)', | |
| 1158 | + // [a-z0-9_-]+ — the HYPHEN is the one that matters, not the digit. | |
| 1159 | + // Generated tool names carry their module slug verbatim, and 33 of | |
| 1160 | + // the 92 in the catalog have a hyphenated slug | |
| 1161 | + // (xspeed_cache-404_status, xspeed_migration-pro_apply, | |
| 1162 | + // xspeed_smart-predict_status …). Every one of those returned | |
| 1163 | + // rest_no_route through the broker path. The earlier widening to | |
| 1164 | + // [a-z0-9_]+ un-blocked nothing: the only digit-bearing name is | |
| 1165 | + // cache-404, whose problem was the hyphen. (QA on #158) */ | |
| 1166 | + '/mcp/tool/(?P<tool>[a-z0-9_-]+)', | |
| 459 | 1167 | array( |
| 460 | 1168 | array( |
| 461 | 1169 | 'methods' => 'GET', |
| 462 | 1170 | 'callback' => array( $this, 'rest_tool' ), |
| @@ -496,8 +1204,35 @@ | ||
| 496 | 1204 | return $response; |
| 497 | 1205 | } |
| 498 | 1206 | |
| 499 | 1207 | /** |
| 1208 | + * GET /signals — the public scan-signals body. See the route | |
| 1209 | + * registration for what may (and may not) leave through it. | |
| 1210 | + * | |
| 1211 | + * @return \WP_REST_Response | |
| 1212 | + */ | |
| 1213 | + public function rest_signals() { | |
| 1214 | + $signals = array( | |
| 1215 | + 'xspeed' => XSPEED_VERSION, | |
| 1216 | + 'mcp' => '' !== Mcp_Pairing::site_token(), | |
| 1217 | + 'hub' => Mcp_Hub::site_attached(), | |
| 1218 | + ); | |
| 1219 | + | |
| 1220 | + /** | |
| 1221 | + * Filter the public scan signals. | |
| 1222 | + * | |
| 1223 | + * Lets an add-on append its own public facts (e.g. its version | |
| 1224 | + * under `pro`). Values returned here are served UNAUTHENTICATED — | |
| 1225 | + * never add tokens, accounts, emails, or paths. | |
| 1226 | + * | |
| 1227 | + * @param array<string,mixed> $signals The signals body. | |
| 1228 | + */ | |
| 1229 | + $signals = (array) apply_filters( 'xspeed_scan_signals', $signals ); | |
| 1230 | + | |
| 1231 | + return rest_ensure_response( $signals ); | |
| 1232 | + } | |
| 1233 | + | |
| 1234 | + /** | |
| 500 | 1235 | * GET /mcp/connection — pairing status for the dashboard. |
| 501 | 1236 | * |
| 502 | 1237 | * @param \WP_REST_Request $request Unused. |
| 503 | 1238 | * @return \WP_REST_Response |
| @@ -507,8 +1242,44 @@ | ||
| 507 | 1242 | return rest_ensure_response( Mcp_Pairing::public_status() ); |
| 508 | 1243 | } |
| 509 | 1244 | |
| 510 | 1245 | /** |
| 1246 | + * GET /mcp/activity — the audit trail of AI tool calls. | |
| 1247 | + * | |
| 1248 | + * @param \WP_REST_Request $request Carries the optional limit. | |
| 1249 | + * @return \WP_REST_Response|\WP_Error | |
| 1250 | + */ | |
| 1251 | + public function rest_activity( \WP_REST_Request $request ) { | |
| 1252 | + $limit = (int) $request->get_param( 'limit' ); | |
| 1253 | + | |
| 1254 | + return rest_ensure_response( | |
| 1255 | + array( | |
| 1256 | + 'entries' => Mcp_Activity_Log::entries( $limit > 0 ? $limit : 50 ), | |
| 1257 | + 'summary' => Mcp_Activity_Log::summary(), | |
| 1258 | + ) | |
| 1259 | + ); | |
| 1260 | + } | |
| 1261 | + | |
| 1262 | + /** | |
| 1263 | + * POST /mcp/activity/clear — wipe the audit trail. | |
| 1264 | + * | |
| 1265 | + * @param \WP_REST_Request $request Unused. | |
| 1266 | + * @return \WP_REST_Response|\WP_Error | |
| 1267 | + */ | |
| 1268 | + public function rest_activity_clear( \WP_REST_Request $request ) { | |
| 1269 | + unset( $request ); | |
| 1270 | + $cleared = Mcp_Activity_Log::clear(); | |
| 1271 | + | |
| 1272 | + return rest_ensure_response( | |
| 1273 | + array( | |
| 1274 | + 'cleared' => $cleared, | |
| 1275 | + 'entries' => Mcp_Activity_Log::entries(), | |
| 1276 | + 'summary' => Mcp_Activity_Log::summary(), | |
| 1277 | + ) | |
| 1278 | + ); | |
| 1279 | + } | |
| 1280 | + | |
| 1281 | + /** | |
| 511 | 1282 | * POST /mcp/connect — mint a connection token. |
| 512 | 1283 | * |
| 513 | 1284 | * @param \WP_REST_Request $request Unused. |
| 514 | 1285 | * @return \WP_REST_Response|\WP_Error |
| @@ -606,19 +1377,64 @@ | ||
| 606 | 1377 | return rest_ensure_response( Mcp_Hub::mark_attached( $email ) ); |
| 607 | 1378 | } |
| 608 | 1379 | |
| 609 | 1380 | /** |
| 610 | - * POST /mcp/hub/disconnect — clear the local hub-link bookkeeping. | |
| 1381 | + * POST /mcp/hub/disconnect — tell the hub to drop this admin's link and | |
| 1382 | + * clear the local bookkeeping. | |
| 611 | 1383 | * |
| 612 | - * @param \WP_REST_Request $request Unused. | |
| 613 | - * @return \WP_REST_Response | |
| 1384 | + * Answers 409 while something reports a reason not to disconnect and the | |
| 1385 | + * request did not send `acknowledge`. The blockers travel in the error | |
| 1386 | + * data so a non-UI client gets the same reason a human would read. | |
| 1387 | + * | |
| 1388 | + * @param \WP_REST_Request $request Carries the optional `acknowledge` flag. | |
| 1389 | + * @return \WP_REST_Response|\WP_Error | |
| 614 | 1390 | */ |
| 615 | 1391 | public function rest_hub_disconnect( \WP_REST_Request $request ) { |
| 616 | - unset( $request ); | |
| 617 | - return rest_ensure_response( Mcp_Hub::disconnect() ); | |
| 1392 | + $result = Mcp_Hub::disconnect( (bool) $request->get_param( 'acknowledge' ) ); | |
| 1393 | + | |
| 1394 | + if ( ! empty( $result['blocked'] ) ) { | |
| 1395 | + $blockers = isset( $result['blockers'] ) && is_array( $result['blockers'] ) | |
| 1396 | + ? $result['blockers'] | |
| 1397 | + : array(); | |
| 1398 | + $reasons = trim( implode( ' ', array_column( $blockers, 'message' ) ) ); | |
| 1399 | + | |
| 1400 | + return new \WP_Error( | |
| 1401 | + 'xspeed_hub_disconnect_blocked', | |
| 1402 | + '' !== $reasons | |
| 1403 | + ? $reasons | |
| 1404 | + : __( 'Disconnecting is blocked while this site depends on the hub connection.', 'xspeed' ), | |
| 1405 | + array( | |
| 1406 | + 'status' => 409, | |
| 1407 | + 'blockers' => $blockers, | |
| 1408 | + ) | |
| 1409 | + ); | |
| 1410 | + } | |
| 1411 | + | |
| 1412 | + return rest_ensure_response( $result ); | |
| 618 | 1413 | } |
| 619 | 1414 | |
| 1415 | + /** GET /hub/connect-info — whether this site supports connecting from the Hub. */ | |
| 1416 | + public function rest_hub_connect_info(): \WP_REST_Response { | |
| 1417 | + return rest_ensure_response( Mcp_Hub_Connect::connect_info() ); | |
| 1418 | + } | |
| 1419 | + | |
| 620 | 1420 | /** |
| 1421 | + * Check an attach callback: a nonce (plugin-started) or a code with its | |
| 1422 | + * PKCE verifier (Hub-started, xspeed-hub#307). Null when neither holds. | |
| 1423 | + * | |
| 1424 | + * @return array{site_url:string,site_token:string,user_id:int}|null | |
| 1425 | + */ | |
| 1426 | + private static function attach_result( string $nonce, string $code, string $verifier ): ?array { | |
| 1427 | + if ( '' !== $nonce ) { | |
| 1428 | + return Mcp_Hub::verify_attach_nonce( $nonce ); | |
| 1429 | + } | |
| 1430 | + if ( '' !== $code ) { | |
| 1431 | + return Mcp_Hub_Connect::redeem_code( $code, $verifier ); | |
| 1432 | + } | |
| 1433 | + return null; | |
| 1434 | + } | |
| 1435 | + | |
| 1436 | + /** | |
| 621 | 1437 | * POST /mcp/attach — the OAuth-attach callback. The hub presents the |
| 622 | 1438 | * signed nonce the plugin issued; on success we return this site's URL + |
| 623 | 1439 | * token so the hub can record it. Nonce is the auth (admin-minted, |
| 624 | 1440 | * HMAC-signed, time-bound), so no pre-shared token is required. |
| @@ -626,10 +1442,13 @@ | ||
| 626 | 1442 | * @param \WP_REST_Request $request Carries the nonce. |
| 627 | 1443 | * @return \WP_REST_Response|\WP_Error |
| 628 | 1444 | */ |
| 629 | 1445 | public function rest_hub_attach_callback( \WP_REST_Request $request ) { |
| 630 | - $nonce = (string) $request->get_param( 'nonce' ); | |
| 631 | - $result = Mcp_Hub::verify_attach_nonce( $nonce ); | |
| 1446 | + $result = self::attach_result( | |
| 1447 | + (string) $request->get_param( 'nonce' ), | |
| 1448 | + (string) $request->get_param( 'code' ), | |
| 1449 | + (string) $request->get_param( 'code_verifier' ) | |
| 1450 | + ); | |
| 632 | 1451 | if ( null === $result ) { |
| 633 | 1452 | return new \WP_Error( |
| 634 | 1453 | 'xspeed_attach_invalid', |
| 635 | 1454 | __( 'Invalid or expired attach request.', 'xspeed' ), |
| @@ -652,8 +1471,43 @@ | ||
| 652 | 1471 | |
| 653 | 1472 | // -- OAuth 2.1 handlers ------------------------------------------------ |
| 654 | 1473 | |
| 655 | 1474 | /** |
| 1475 | + * GET /mcp/.well-known/oauth-protected-resource — RFC 9728 metadata. | |
| 1476 | + * | |
| 1477 | + * Byte-identical to what the /.well-known rewrite serves; both call the | |
| 1478 | + * same builder so the two locations can never drift. | |
| 1479 | + * | |
| 1480 | + * @return \WP_REST_Response | |
| 1481 | + */ | |
| 1482 | + public function rest_protected_resource_metadata(): \WP_REST_Response { | |
| 1483 | + return $this->discovery_response( Mcp_OAuth::protected_resource_metadata() ); | |
| 1484 | + } | |
| 1485 | + | |
| 1486 | + /** | |
| 1487 | + * GET /mcp/.well-known/oauth-authorization-server — RFC 8414 metadata. | |
| 1488 | + * | |
| 1489 | + * @return \WP_REST_Response | |
| 1490 | + */ | |
| 1491 | + public function rest_authorization_server_metadata(): \WP_REST_Response { | |
| 1492 | + return $this->discovery_response( Mcp_OAuth::authorization_server_metadata() ); | |
| 1493 | + } | |
| 1494 | + | |
| 1495 | + /** | |
| 1496 | + * Wrap a discovery document in a public, cacheable REST response. | |
| 1497 | + * | |
| 1498 | + * @param array<string,mixed> $data The metadata document. | |
| 1499 | + * @return \WP_REST_Response | |
| 1500 | + */ | |
| 1501 | + private function discovery_response( array $data ): \WP_REST_Response { | |
| 1502 | + $response = new \WP_REST_Response( $data, 200 ); | |
| 1503 | + // Same short window as the /.well-known/ emit site, same reason: the | |
| 1504 | + // document carries the server's identity. (#266) | |
| 1505 | + $response->header( 'Cache-Control', 'public, max-age=300' ); | |
| 1506 | + return $response; | |
| 1507 | + } | |
| 1508 | + | |
| 1509 | + /** | |
| 656 | 1510 | * POST /mcp/oauth/register — RFC 7591 dynamic client registration. |
| 657 | 1511 | * |
| 658 | 1512 | * @param \WP_REST_Request $request JSON body with redirect_uris. |
| 659 | 1513 | * @return \WP_REST_Response|\WP_Error |
| @@ -768,8 +1622,61 @@ | ||
| 768 | 1622 | return $response; |
| 769 | 1623 | } |
| 770 | 1624 | |
| 771 | 1625 | /** |
| 1626 | + * Reject an over-sized MCP request body before WordPress decodes it. | |
| 1627 | + * | |
| 1628 | + * `rest_pre_dispatch` is the last hook that runs before | |
| 1629 | + * `WP_REST_Server::dispatch()` calls `has_valid_params()`, and that is | |
| 1630 | + * what `json_decode()`s the body — before the permission callback, so an | |
| 1631 | + * unauthenticated caller already pays for the decode. Capping here is the | |
| 1632 | + * difference between reading a length and parsing megabytes of JSON. | |
| 1633 | + * | |
| 1634 | + * Refusing is not enough on its own. Core reads request params again on | |
| 1635 | + * the way out — `rest_filter_response_fields()` on `rest_post_dispatch` | |
| 1636 | + * looks up `_fields` — and for an `application/json` request that lookup | |
| 1637 | + * runs `parse_json_params()` over whatever body is still attached. So a | |
| 1638 | + * refused body is also emptied, and the 413 goes out with nothing left to | |
| 1639 | + * decode. QA measured a refused 3 MB body peaking near 90 MB without this. | |
| 1640 | + * | |
| 1641 | + * Scoped to the two routes that carry tool payloads, compared | |
| 1642 | + * case-insensitively: `WP_REST_Server::match_request_to_handler()` matches | |
| 1643 | + * routes with the `i` flag, so `/XSPEED/v1/MCP` reaches the same handler | |
| 1644 | + * and has to meet the same cap. Returning null leaves the request alone, | |
| 1645 | + * which is what this filter does for everything else. | |
| 1646 | + * | |
| 1647 | + * @param mixed $result A short-circuit response, if one is set. | |
| 1648 | + * @param mixed $server Unused; the REST server instance. | |
| 1649 | + * @param \WP_REST_Request $request Incoming request. | |
| 1650 | + * @return mixed Null to continue, or a WP_Error to refuse. | |
| 1651 | + */ | |
| 1652 | + public function cap_request_body( $result, $server = null, $request = null ) { | |
| 1653 | + if ( null !== $result || ! $request instanceof \WP_REST_Request ) { | |
| 1654 | + return $result; | |
| 1655 | + } | |
| 1656 | + | |
| 1657 | + $route = strtolower( (string) $request->get_route() ); | |
| 1658 | + $mcp = '/' . self::NS . '/mcp'; | |
| 1659 | + if ( $route !== $mcp && 0 !== strpos( $route, $mcp . '/tool/' ) ) { | |
| 1660 | + return $result; | |
| 1661 | + } | |
| 1662 | + | |
| 1663 | + if ( strlen( (string) $request->get_body() ) <= Mcp_Tools::MAX_TOOL_BODY_BYTES ) { | |
| 1664 | + return $result; | |
| 1665 | + } | |
| 1666 | + | |
| 1667 | + // Drop the body before refusing. Nothing downstream needs it, and | |
| 1668 | + // core's response pipeline would otherwise json_decode() it anyway. | |
| 1669 | + $request->set_body( '' ); | |
| 1670 | + | |
| 1671 | + return new \WP_Error( | |
| 1672 | + 'xspeed_mcp_tool_payload_too_large', | |
| 1673 | + __( 'The MCP tool payload is too large.', 'xspeed' ), | |
| 1674 | + array( 'status' => 413 ) | |
| 1675 | + ); | |
| 1676 | + } | |
| 1677 | + | |
| 1678 | + /** | |
| 772 | 1679 | * Token-authenticated tool route for the hosted broker. Maps a broker |
| 773 | 1680 | * tool call (e.g. GET /mcp/tool/get_cache_status) onto the shared |
| 774 | 1681 | * Mcp_Tools catalog, so the broker path and the JSON-RPC path never |
| 775 | 1682 | * drift. GET params + JSON body both feed the tool's arguments. |
| @@ -775,8 +1682,18 @@ | ||
| 775 | 1682 | * drift. GET params + JSON body both feed the tool's arguments. |
| 776 | 1683 | */ |
| 777 | 1684 | public function rest_tool( \WP_REST_Request $request ) { |
| 778 | 1685 | $tool = (string) $request->get_param( 'tool' ); |
| 1686 | + // Second line behind cap_request_body(). This one still matters: the | |
| 1687 | + // pretty front-door path builds its own WP_REST_Request and calls the | |
| 1688 | + // handler without going through WP_REST_Server::dispatch() at all. | |
| 1689 | + if ( strlen( $request->get_body() ) > Mcp_Tools::MAX_TOOL_BODY_BYTES ) { | |
| 1690 | + return new \WP_Error( | |
| 1691 | + 'xspeed_mcp_tool_payload_too_large', | |
| 1692 | + __( 'The MCP tool payload is too large.', 'xspeed' ), | |
| 1693 | + array( 'status' => 413 ) | |
| 1694 | + ); | |
| 1695 | + } | |
| 779 | 1696 | $args = $request->get_json_params(); |
| 780 | 1697 | if ( ! is_array( $args ) ) { |
| 781 | 1698 | $args = array(); |
| 782 | 1699 | } |
| @@ -786,8 +1703,9 @@ | ||
| 786 | 1703 | $args[ $k ] = $v; |
| 787 | 1704 | } |
| 788 | 1705 | } |
| 789 | 1706 | |
| 1707 | + Mcp_Tools::set_channel( 'broker' ); | |
| 790 | 1708 | $result = Mcp_Tools::invoke( $tool, $args ); |
| 791 | 1709 | if ( is_wp_error( $result ) ) { |
| 792 | 1710 | return $result; |
| 793 | 1711 | } |
| @@ -867,18 +1785,9 @@ | ||
| 867 | 1785 | header( 'Content-Type: text/html; charset=utf-8' ); |
| 868 | 1786 | header( 'Cache-Control: no-store' ); |
| 869 | 1787 | |
| 870 | 1788 | echo '<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>' . esc_html__( 'Authorize AI access', 'xspeed' ) . '</title>'; |
| 871 | - echo '<style>' | |
| 872 | - . '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}' | |
| 873 | - . '.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)}' | |
| 874 | - . 'h1{font-size:20px;margin:0 0 4px}.sub{color:#94a3b8;font-size:13px;margin:0 0 24px}' | |
| 875 | - . '.row{display:flex;justify-content:space-between;padding:10px 0;border-bottom:1px solid #334155;font-size:13px}' | |
| 876 | - . '.row span:first-child{color:#94a3b8}.row span:last-child{font-weight:600;text-align:right;max-width:60%;word-break:break-word}' | |
| 877 | - . '.actions{display:flex;gap:12px;margin-top:24px}' | |
| 878 | - . 'button{flex:1;padding:12px;border-radius:10px;border:0;font-size:14px;font-weight:600;cursor:pointer}' | |
| 879 | - . '.approve{background:#f5cd47;color:#1b2533}.deny{background:transparent;color:#94a3b8;border:1px solid #334155}' | |
| 880 | - . '</style></head><body><div class="card">'; | |
| 1789 | + echo '<style>' . self::CONSENT_CSS . '</style></head><body><div class="card">'; // phpcs:ignore WordPress.Security.EscapeOutput -- static stylesheet constant. | |
| 881 | 1790 | echo '<h1>' . esc_html__( 'Connect to xSpeed', 'xspeed' ) . '</h1>'; |
| 882 | 1791 | /* translators: %s: AI client name. */ |
| 883 | 1792 | echo '<p class="sub">' . esc_html( sprintf( __( '%s wants to manage the cache on this site.', 'xspeed' ), $client ) ) . '</p>'; |
| 884 | 1793 | echo '<div class="row"><span>' . esc_html__( 'Site', 'xspeed' ) . '</span><span>' . esc_html( wp_parse_url( home_url(), PHP_URL_HOST ) ) . '</span></div>'; |
| @@ -944,8 +1853,27 @@ | ||
| 944 | 1853 | 'shortdesc' => 'Show MCP connection status and the paste-in endpoint URL.', |
| 945 | 1854 | 'synopsis' => array(), |
| 946 | 1855 | ), |
| 947 | 1856 | array( |
| 1857 | + 'name' => 'xspeed mcp activity', | |
| 1858 | + 'callback' => array( $this, 'cli_activity' ), | |
| 1859 | + 'shortdesc' => 'List recent MCP tool calls (the AI audit trail).', | |
| 1860 | + 'synopsis' => array( | |
| 1861 | + array( | |
| 1862 | + 'name' => 'limit', | |
| 1863 | + 'type' => 'assoc', | |
| 1864 | + 'optional' => true, | |
| 1865 | + 'description' => 'Maximum entries to show (default 20).', | |
| 1866 | + ), | |
| 1867 | + array( | |
| 1868 | + 'name' => 'clear', | |
| 1869 | + 'type' => 'flag', | |
| 1870 | + 'optional' => true, | |
| 1871 | + 'description' => 'Wipe the audit trail instead of listing it.', | |
| 1872 | + ), | |
| 1873 | + ), | |
| 1874 | + ), | |
| 1875 | + array( | |
| 948 | 1876 | 'name' => 'xspeed mcp connect', |
| 949 | 1877 | 'callback' => array( $this, 'cli_connect' ), |
| 950 | 1878 | 'shortdesc' => 'Generate a connection token for this site\'s MCP endpoint.', |
| 951 | 1879 | 'synopsis' => array( |
| @@ -998,8 +1926,59 @@ | ||
| 998 | 1926 | } |
| 999 | 1927 | } |
| 1000 | 1928 | |
| 1001 | 1929 | /** |
| 1930 | + * `wp xspeed mcp activity` — read (or clear) the AI audit trail. | |
| 1931 | + * | |
| 1932 | + * @param array $args Positional args (unused). | |
| 1933 | + * @param array $assoc --limit=<n>, --clear. | |
| 1934 | + */ | |
| 1935 | + public function cli_activity( array $args, array $assoc ): void { | |
| 1936 | + unset( $args ); | |
| 1937 | + | |
| 1938 | + if ( ! empty( $assoc['clear'] ) ) { | |
| 1939 | + if ( ! Mcp_Activity_Log::clear() ) { | |
| 1940 | + // Reached via MCP run_command — the assistant is asking to | |
| 1941 | + // erase the record of its own calls. Mcp_Activity_Log::clear() | |
| 1942 | + // declines and logs the attempt; say so plainly. | |
| 1943 | + \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.' ); | |
| 1944 | + return; | |
| 1945 | + } | |
| 1946 | + \WP_CLI::success( 'MCP activity log cleared.' ); | |
| 1947 | + return; | |
| 1948 | + } | |
| 1949 | + | |
| 1950 | + $limit = isset( $assoc['limit'] ) ? (int) $assoc['limit'] : 20; | |
| 1951 | + $summary = Mcp_Activity_Log::summary(); | |
| 1952 | + $entries = Mcp_Activity_Log::entries( $limit > 0 ? $limit : 20 ); | |
| 1953 | + | |
| 1954 | + \WP_CLI::log( sprintf( '%-18s %d', 'total_calls', $summary['total'] ) ); | |
| 1955 | + \WP_CLI::log( sprintf( '%-18s %d', 'failed', $summary['failed'] ) ); | |
| 1956 | + \WP_CLI::log( sprintf( '%-18s %s', 'top_tool', '' === $summary['top_tool'] ? '-' : $summary['top_tool'] ) ); | |
| 1957 | + | |
| 1958 | + if ( empty( $entries ) ) { | |
| 1959 | + \WP_CLI::log( '' ); | |
| 1960 | + \WP_CLI::log( 'No MCP tool calls recorded yet.' ); | |
| 1961 | + return; | |
| 1962 | + } | |
| 1963 | + | |
| 1964 | + \WP_CLI::log( '' ); | |
| 1965 | + foreach ( $entries as $entry ) { | |
| 1966 | + \WP_CLI::log( | |
| 1967 | + sprintf( | |
| 1968 | + '%s %-22s %-5s %-6s %s%s', | |
| 1969 | + gmdate( 'Y-m-d H:i:s', $entry['ts'] ), | |
| 1970 | + $entry['tool'], | |
| 1971 | + $entry['scope'], | |
| 1972 | + $entry['ok'] ? 'ok' : 'FAIL', | |
| 1973 | + $entry['args'], | |
| 1974 | + '' === $entry['error'] ? '' : ' — ' . $entry['error'] | |
| 1975 | + ) | |
| 1976 | + ); | |
| 1977 | + } | |
| 1978 | + } | |
| 1979 | + | |
| 1980 | + /** | |
| 1002 | 1981 | * `wp xspeed mcp connect` — mint a token and print the paste-in URL. |
| 1003 | 1982 | * |
| 1004 | 1983 | * @param array $args Positional args (unused). |
| 1005 | 1984 | * @param array $assoc Associative args (unused). |
| @@ -1042,6 +2021,27 @@ | ||
| 1042 | 2021 | public function cli_disconnect( array $args, array $assoc ): void { |
| 1043 | 2022 | unset( $args, $assoc ); |
| 1044 | 2023 | Mcp_Pairing::disconnect(); |
| 1045 | 2024 | \WP_CLI::success( 'Disconnected and revoked the MCP token.' ); |
| 2025 | + } | |
| 2026 | + | |
| 2027 | + /** | |
| 2028 | + * MCP is on when a connection token exists -- it has no `enabled` | |
| 2029 | + * setting, so the sidebar counted the AI group as empty on a site with | |
| 2030 | + * a live read-write AI connection. Reads the same | |
| 2031 | + * `Mcp_Pairing::public_status()` the CLI and the panel do, so the count | |
| 2032 | + * cannot disagree with the badge on the panel. (#363) | |
| 2033 | + */ | |
| 2034 | + public function is_active(): ?bool { | |
| 2035 | + $status = Mcp_Pairing::public_status(); | |
| 2036 | + return ! empty( $status['connected'] ); | |
| 2037 | + } | |
| 2038 | + | |
| 2039 | + /** | |
| 2040 | + * MCP has no on/off setting -- it is on when a connection exists. | |
| 2041 | + */ | |
| 2042 | + public function active_reason(): ?string { | |
| 2043 | + return $this->is_active() | |
| 2044 | + ? __( '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' ) | |
| 2045 | + : __( 'No AI assistant is connected. This module counts as on once you connect one.', 'xspeed' ); | |
| 1046 | 2046 | } |
| 1047 | 2047 | } |