| @@ -36,8 +36,9 @@ | ||
| 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; |
| 41 | 42 | use XSpeed\Onboarding; |
| 42 | 43 | |
| 43 | 44 | defined( 'ABSPATH' ) || exit; |
| @@ -63,20 +64,57 @@ | ||
| 63 | 64 | public const REWRITE_RULES = array( |
| 64 | 65 | '^xspeed/mcp/([a-f0-9]{64})/?$', |
| 65 | 66 | '^xspeed/mcp/?$', |
| 66 | 67 | '^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. | |
| 68 | + // OAuth discovery. RFC 9728 §3.1 / RFC 8414 §3.1 put the | |
| 69 | + // `.well-known` segment BEFORE the resource/issuer path, and both of | |
| 70 | + // our identifiers are /xspeed/mcp — so this pair of URLs, and only | |
| 71 | + // this pair, is ours. It names our own path explicitly: a catch-all | |
| 72 | + // tail here also matched other MCP plugins' discovery URLs on the | |
| 73 | + // same site and answered them with our metadata, which broke their | |
| 74 | + // connectors. The bare root form is registered conditionally and so | |
| 75 | + // lives apart, in ROOT_DISCOVERY_RULE. | |
| 75 | 76 | '^\.well-known/oauth-(protected-resource|authorization-server)/xspeed/mcp/?$', |
| 76 | 77 | '^xspeed/authorize/?$', |
| 77 | 78 | ); |
| 78 | 79 | |
| 80 | + /** | |
| 81 | + * The root-form discovery rule — registered CONDITIONALLY, which is why | |
| 82 | + * it is not in REWRITE_RULES. | |
| 83 | + * | |
| 84 | + * It is the address a client built to the 2025-03-26 MCP spec looks at, | |
| 85 | + * and the only address it looks at; current clients read the | |
| 86 | + * protected-resource document first and follow it to the path form. So | |
| 87 | + * dropping it outright would cut off older clients on every site, | |
| 88 | + * including the single-plugin sites where the collision #266 exists to | |
| 89 | + * fix never happened. We answer it while it is uncontested and stand | |
| 90 | + * down the moment another plugin's rule claims it — | |
| 91 | + * root_discovery_contested(). | |
| 92 | + */ | |
| 93 | + public const ROOT_DISCOVERY_RULE = '^\.well-known/oauth-(protected-resource|authorization-server)/?$'; | |
| 94 | + | |
| 95 | + /** | |
| 96 | + * Rules earlier builds registered that we never register again under any | |
| 97 | + * condition. The self-heal guard flushes once when it finds OUR copy of | |
| 98 | + * one still in the stored table. | |
| 99 | + * | |
| 100 | + * The catch-all below shipped in an intermediate build and matched every | |
| 101 | + * path-suffixed discovery URL on the site, including other MCP plugins' | |
| 102 | + * (#264). Nothing brings it back, so its removal is unconditional — | |
| 103 | + * unlike ROOT_DISCOVERY_RULE, which is a rule we still register when the | |
| 104 | + * root URL is uncontested and therefore cannot live in this list. | |
| 105 | + * | |
| 106 | + * Ownership is read from the rule's TARGET, never from the regex alone: | |
| 107 | + * a sibling may register the same regex for its own document, its rule | |
| 108 | + * comes back from every flush, and a guard that treated that as stale | |
| 109 | + * would flush on every request forever. | |
| 110 | + * | |
| 111 | + * @var string[] | |
| 112 | + */ | |
| 113 | + public const RETIRED_REWRITE_RULES = array( | |
| 114 | + '^\.well-known/oauth-(protected-resource|authorization-server)(?:/.*)?/?$', | |
| 115 | + ); | |
| 116 | + | |
| 79 | 117 | /** REST namespace shared with Free. Public: Mcp_Server builds the |
| 80 | 118 | * discovery fallback URL from it. */ |
| 81 | 119 | public const NS = 'xspeed/v1'; |
| 82 | 120 | |
| @@ -109,10 +147,11 @@ | ||
| 109 | 147 | public function ui_metadata(): array { |
| 110 | 148 | return array( |
| 111 | 149 | 'label' => __( 'MCP Server', 'xspeed' ), |
| 112 | 150 | 'icon' => 'Sparkles', |
| 113 | - 'description' => __( 'Control this site\'s cache from Claude and other AI agents.', 'xspeed' ), | |
| 151 | + 'description' => __( 'Let Claude or another AI assistant clear the cache, check stats and change settings.', 'xspeed' ), | |
| 114 | 152 | 'custom_panel' => 'McpPanel', |
| 153 | + 'group' => 'ai-agents', | |
| 115 | 154 | ); |
| 116 | 155 | } |
| 117 | 156 | |
| 118 | 157 | /** |
| @@ -133,11 +172,40 @@ | ||
| 133 | 172 | } |
| 134 | 173 | |
| 135 | 174 | public function boot(): void { |
| 136 | 175 | add_action( 'rest_api_init', array( $this, 'register_rest' ) ); |
| 176 | + add_action( Mcp_Pairing::TOKEN_CHANGED_ACTION, array( Mcp_Hub::class, 'on_token_changed' ) ); | |
| 137 | 177 | |
| 178 | + // The only place the body cap can run before WordPress decodes it. | |
| 179 | + // WP_REST_Server::dispatch() fires rest_pre_dispatch, and only after | |
| 180 | + // that calls has_valid_params() — which json_decode()s the whole body | |
| 181 | + // for any application/json request, ahead of the permission callback | |
| 182 | + // and the handler. A cap inside a handler is therefore a second line, | |
| 183 | + // not the bound it reads like. | |
| 184 | + add_filter( 'rest_pre_dispatch', array( $this, 'cap_request_body' ), 10, 3 ); | |
| 185 | + | |
| 138 | 186 | // Pretty per-site endpoint: /xspeed/mcp → MCP JSON-RPC handler. |
| 139 | - add_action( 'init', array( $this, 'add_rewrite' ) ); | |
| 187 | + // `wp_loaded`, not `init`: add_rewrite() decides whether to claim the | |
| 188 | + // root discovery URL by looking at the rewrite table, and on `init` that | |
| 189 | + // view is incomplete -- a sibling MCP plugin hooked at the same priority | |
| 190 | + // but loaded after us has not registered yet. Root then looks | |
| 191 | + // uncontested, we register our rule, and the self-heal guard concludes | |
| 192 | + // nothing is stale, so it never flushes. That is a fixed point: the | |
| 193 | + // table never converges, and because our rule is the one WordPress | |
| 194 | + // matches, the sibling never sees the request either. | |
| 195 | + // | |
| 196 | + // By `wp_loaded` every init callback on every request type has run, so | |
| 197 | + // the contested check sees the sibling and the guard flushes once. | |
| 198 | + // WP_Rewrite::flush_rules() already defers itself to `wp_loaded`, so | |
| 199 | + // nothing is lost by deciding here, and did_action('wp_loaded') is | |
| 200 | + // truthy inside this callback, so the flush lands in time for | |
| 201 | + // parse_request in the same request. (#266 QA) | |
| 202 | + // Priority 0: still after every `init` callback, but ahead of the | |
| 203 | + // widely copied `add_action( 'wp_loaded', 'flush_rewrite_rules' )` | |
| 204 | + // snippet. If such a plugin flushed first it would write a table | |
| 205 | + // without our rules, our guard would find them missing and flush | |
| 206 | + // again -- two flushes and two option writes on every request. | |
| 207 | + add_action( 'wp_loaded', array( $this, 'add_rewrite' ), 0 ); | |
| 140 | 208 | add_filter( 'query_vars', array( $this, 'register_query_var' ) ); |
| 141 | 209 | // Priority 1: a sibling MCP plugin that also claims /.well-known/ gets |
| 142 | 210 | // to answer first at the default priority 10, and whoever answers |
| 143 | 211 | // first calls exit(). Running early means the URL is decided by WHOSE |
| @@ -239,8 +307,15 @@ | ||
| 239 | 307 | |
| 240 | 308 | // -- Pretty endpoint: /xspeed/mcp -- |
| 241 | 309 | |
| 242 | 310 | public function add_rewrite(): void { |
| 311 | + // The stored table is WordPress's routing table AND the only durable | |
| 312 | + // record of which plugin owns which discovery URL, so both the | |
| 313 | + // conditional registration below and the self-heal guard at the | |
| 314 | + // bottom read the SAME snapshot of it. Deciding twice from two reads | |
| 315 | + // is how a guard ends up flushing away a rule it just registered. | |
| 316 | + $stored_rules = get_option( 'rewrite_rules' ); | |
| 317 | + | |
| 243 | 318 | // Token-in-URL form: /xspeed/mcp/<token> — a single string the user |
| 244 | 319 | // pastes into their AI client (no separate token field). The bare |
| 245 | 320 | // /xspeed/mcp still works with a Bearer/header token. |
| 246 | 321 | add_rewrite_rule( |
| @@ -257,55 +332,238 @@ | ||
| 257 | 332 | // (that rule requires 64 hex chars), so ordering is safe. |
| 258 | 333 | add_rewrite_rule( '^xspeed/mcp/attach/?$', 'index.php?' . self::ATTACH_QUERY_VAR . '=1', 'top' ); |
| 259 | 334 | |
| 260 | 335 | // 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. | |
| 336 | + // `.well-known` segment BEFORE the resource/issuer path, and both of | |
| 337 | + // our canonical identifiers are the MCP endpoint URL, so our | |
| 338 | + // documents live at: | |
| 339 | + // /.well-known/oauth-protected-resource/xspeed/mcp | |
| 340 | + // /.well-known/oauth-authorization-server/xspeed/mcp | |
| 267 | 341 | // |
| 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. | |
| 342 | + // Matched EXACTLY. A `(?:/.*)?` tail covers our URLs in one rule, but | |
| 343 | + // also matches every OTHER plugin's discovery URL on the same site — | |
| 344 | + // and WordPress matches rewrite rules in table order rather than by | |
| 345 | + // specificity, so a sibling's own exact rule never gets reached. Its | |
| 346 | + // clients then receive OUR metadata, find a resource and issuer that | |
| 347 | + // do not match what they are connecting to, and abort before the | |
| 348 | + // login screen. | |
| 275 | 349 | 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 | 350 | '^\\.well-known/oauth-(protected-resource|authorization-server)/xspeed/mcp/?$', |
| 282 | 351 | 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]', |
| 283 | 352 | 'top' |
| 284 | 353 | ); |
| 285 | 354 | |
| 355 | + // The bare root form, ONLY while no other plugin claims it. A client | |
| 356 | + // written to the 2025-03-26 MCP spec looks there and nowhere else, so | |
| 357 | + // giving it up unconditionally would break those clients on every | |
| 358 | + // site — including the single-plugin sites where the collision never | |
| 359 | + // happened. When a sibling's rule is present the URL is theirs and we | |
| 360 | + // register nothing, which is the case #266 is about. Current clients | |
| 361 | + // read the protected-resource document first and follow it wherever | |
| 362 | + // it points, so they are unaffected either way. The document served | |
| 363 | + // at root carries the LEGACY | |
| 364 | + // host-only issuer, because that is the identifier a client used to | |
| 365 | + // derive that URL (RFC 8414 §3.3). | |
| 366 | + $root_contested = self::root_discovery_contested( $stored_rules ); | |
| 367 | + if ( ! $root_contested ) { | |
| 368 | + add_rewrite_rule( | |
| 369 | + self::ROOT_DISCOVERY_RULE, | |
| 370 | + 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]', | |
| 371 | + 'top' | |
| 372 | + ); | |
| 373 | + } | |
| 374 | + | |
| 286 | 375 | // Browser-facing OAuth consent page — served OUTSIDE REST so cookie |
| 287 | 376 | // auth (is_user_logged_in) works after the wp-login round-trip. |
| 288 | 377 | add_rewrite_rule( '^xspeed/authorize/?$', 'index.php?' . self::AUTHORIZE_QUERY_VAR . '=1', 'top' ); |
| 289 | 378 | |
| 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; | |
| 379 | + // Self-heal: flush once if the stored rewrite table disagrees with the | |
| 380 | + // rules we just registered. Checking only the first rule is not | |
| 381 | + // enough — a site flushed under an older build (which had /xspeed/mcp | |
| 382 | + // but not the later /xspeed/authorize + /.well-known rules) keeps that | |
| 383 | + // first rule, so the guard never fires and OAuth discovery 404s | |
| 384 | + // forever. Guard on the full set so any newly-added rule triggers a | |
| 385 | + // re-flush. | |
| 386 | + // | |
| 387 | + // The guard must mirror the registration decisions EXACTLY, or it | |
| 388 | + // never reaches a fixed point: | |
| 389 | + // | |
| 390 | + // - Uncontested root: we register it, so the table must hold it | |
| 391 | + // with OUR target. A flush produces exactly that, and the next | |
| 392 | + // request reads the same table and stays uncontested — our own | |
| 393 | + // target never counts as a sibling's. | |
| 394 | + // - Contested root: we register nothing, so OUR copy must be gone. | |
| 395 | + // A flush regenerates the sibling's rule (they register it every | |
| 396 | + // request) but not ours, so the next request is quiet. | |
| 397 | + // | |
| 398 | + // Ownership is read from the TARGET in both directions. Keying on the | |
| 399 | + // regex alone is what produced a flush on every request forever when | |
| 400 | + // a sibling held that regex: their rule comes back from every flush. | |
| 401 | + // | |
| 402 | + // This runs on `wp_loaded` for every request, so the first request after an | |
| 403 | + // upgrade flushes once and the guard is quiet from then on. It cannot | |
| 404 | + // move to Plugin::maybe_upgrade() — that is admin-only and runs at | |
| 405 | + // plugins_loaded 21, i.e. BEFORE init, so a flush there would write a | |
| 406 | + // table without our rules and this guard would flush a second time. | |
| 407 | + if ( ! is_array( $stored_rules ) ) { | |
| 408 | + return; | |
| 409 | + } | |
| 410 | + | |
| 411 | + $stale = false; | |
| 412 | + $retiring = false; | |
| 413 | + foreach ( self::REWRITE_RULES as $rule ) { | |
| 414 | + if ( ! isset( $stored_rules[ $rule ] ) ) { | |
| 415 | + $stale = true; | |
| 416 | + break; | |
| 417 | + } | |
| 418 | + } | |
| 419 | + | |
| 420 | + // Our copy of the root rule must be present exactly when we register | |
| 421 | + // it. Present-and-unwanted is the #266 upgrade; absent-and-wanted is | |
| 422 | + // an older table, or a sibling that has since gone away. | |
| 423 | + $root_is_ours = isset( $stored_rules[ self::ROOT_DISCOVERY_RULE ] ) | |
| 424 | + && self::is_our_rule_target( $stored_rules[ self::ROOT_DISCOVERY_RULE ] ); | |
| 425 | + if ( $root_is_ours === $root_contested ) { | |
| 426 | + $stale = true; | |
| 427 | + $retiring = $root_contested; | |
| 428 | + } | |
| 429 | + | |
| 430 | + // Not an identity move — nothing a site owner can act on — so this | |
| 431 | + // one flushes quietly. | |
| 432 | + foreach ( self::RETIRED_REWRITE_RULES as $rule ) { | |
| 433 | + if ( isset( $stored_rules[ $rule ] ) && self::is_our_rule_target( $stored_rules[ $rule ] ) ) { | |
| 434 | + $stale = true; | |
| 435 | + break; | |
| 436 | + } | |
| 437 | + } | |
| 438 | + | |
| 439 | + if ( ! $stale ) { | |
| 440 | + return; | |
| 441 | + } | |
| 442 | + | |
| 443 | + if ( $retiring ) { | |
| 444 | + // The one moment the identity move is observable to a site owner, | |
| 445 | + // and it happens on a front-end request with no UI attached. Fires | |
| 446 | + // once: after the flush our rule is gone, so the next request | |
| 447 | + // finds nothing to hand over. | |
| 448 | + Activity_Log::record( | |
| 449 | + 'mcp_discovery_moved', | |
| 450 | + __( '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' ), | |
| 451 | + Activity_Log::INFO | |
| 452 | + ); | |
| 453 | + } | |
| 454 | + | |
| 455 | + flush_rewrite_rules( false ); | |
| 456 | + } | |
| 457 | + | |
| 458 | + /** | |
| 459 | + * Whether another plugin's rewrite rule already routes the ROOT discovery | |
| 460 | + * URLs, making them theirs rather than ours. | |
| 461 | + * | |
| 462 | + * Read from two views of the rewrite table, because neither alone is | |
| 463 | + * complete at `init`: | |
| 464 | + * | |
| 465 | + * - the STORED table, which is what WordPress actually routes with and | |
| 466 | + * the only view that survives the request. If a sibling registered | |
| 467 | + * the same regex after us at the last flush, its target is what is | |
| 468 | + * stored, and that is precisely "the sibling owns this URL now". | |
| 469 | + * - the IN-MEMORY rules registered so far this request, which catches a | |
| 470 | + * sibling that hooks `init` earlier than we do and has therefore not | |
| 471 | + * reached the stored table yet. | |
| 472 | + * | |
| 473 | + * A sibling that registers LATER than us used to be the one case neither | |
| 474 | + * view saw, and it did NOT resolve itself: the guard is what triggers a | |
| 475 | + * flush, so a guard reading an incomplete view simply never fires. That | |
| 476 | + * is why add_rewrite() now runs on `wp_loaded` rather than `init` — by | |
| 477 | + * then every plugin has registered, whatever its load order. | |
| 478 | + * | |
| 479 | + * @param mixed $stored The stored rewrite table, if already read. | |
| 480 | + */ | |
| 481 | + public static function root_discovery_contested( $stored = null ): bool { | |
| 482 | + $tables = array(); | |
| 483 | + if ( is_array( $stored ) ) { | |
| 484 | + $tables[] = $stored; | |
| 485 | + } elseif ( null === $stored ) { | |
| 486 | + $option = get_option( 'rewrite_rules' ); | |
| 487 | + if ( is_array( $option ) ) { | |
| 488 | + $tables[] = $option; | |
| 489 | + } | |
| 490 | + } | |
| 491 | + | |
| 492 | + if ( isset( $GLOBALS['wp_rewrite'] ) && is_object( $GLOBALS['wp_rewrite'] ) ) { | |
| 493 | + foreach ( array( 'extra_rules_top', 'extra_rules' ) as $prop ) { | |
| 494 | + if ( isset( $GLOBALS['wp_rewrite']->$prop ) && is_array( $GLOBALS['wp_rewrite']->$prop ) ) { | |
| 495 | + $tables[] = $GLOBALS['wp_rewrite']->$prop; | |
| 302 | 496 | } |
| 303 | 497 | } |
| 304 | 498 | } |
| 499 | + | |
| 500 | + foreach ( $tables as $rules ) { | |
| 501 | + foreach ( $rules as $pattern => $target ) { | |
| 502 | + if ( ! self::is_a_wellknown_rule( (string) $pattern ) || self::is_our_rule_target( $target ) ) { | |
| 503 | + continue; | |
| 504 | + } | |
| 505 | + foreach ( array( 'protected-resource', 'authorization-server' ) as $doc ) { | |
| 506 | + $probe = '.well-known/oauth-' . $doc; | |
| 507 | + if ( preg_match( '#' . str_replace( '#', '\\#', (string) $pattern ) . '#', $probe ) ) { | |
| 508 | + return true; | |
| 509 | + } | |
| 510 | + } | |
| 511 | + } | |
| 512 | + } | |
| 513 | + | |
| 514 | + return false; | |
| 305 | 515 | } |
| 306 | 516 | |
| 307 | 517 | /** |
| 518 | + * Whether a rewrite regex was written FOR a .well-known discovery URL, | |
| 519 | + * as opposed to merely matching one. | |
| 520 | + * | |
| 521 | + * WordPress's own page rule -- `(.?.+?)/?$` => `index.php?pagename=...` | |
| 522 | + * -- is in the stored table of every site using pretty permalinks, and | |
| 523 | + * it matches `.well-known/oauth-protected-resource` exactly as it | |
| 524 | + * matches every other path on the site. Reading that as a sibling's | |
| 525 | + * claim would report root as contested EVERYWHERE: the root document | |
| 526 | + * would be retired on every install, including the single-plugin sites | |
| 527 | + * this change exists to leave alone, and each of them would log a | |
| 528 | + * hand-over that never happened. | |
| 529 | + * | |
| 530 | + * A rule that routes these URLs on purpose spells the segment out, so | |
| 531 | + * that is the signal. Backslashes are stripped first because the regex | |
| 532 | + * carries them as escapes (`^\\.well-known/...`) and a rule is free to | |
| 533 | + * escape the hyphen too. | |
| 534 | + * | |
| 535 | + * @param string $pattern The stored rewrite regex. | |
| 536 | + */ | |
| 537 | + private static function is_a_wellknown_rule( string $pattern ): bool { | |
| 538 | + return false !== stripos( str_replace( '\\', '', $pattern ), 'well-known' ); | |
| 539 | + } | |
| 540 | + | |
| 541 | + /** | |
| 542 | + * Whether a stored rewrite target was written by this module. | |
| 543 | + * | |
| 544 | + * Every rule we register resolves to `index.php?<one of our query | |
| 545 | + * vars>=…`, and no other plugin sets those. Used to tell OUR leftover | |
| 546 | + * copy of a retired rule from a sibling's rule that happens to share the | |
| 547 | + * regex — only the first is ours to flush away. | |
| 548 | + * | |
| 549 | + * @param mixed $target The stored rewrite target. | |
| 550 | + */ | |
| 551 | + private static function is_our_rule_target( $target ): bool { | |
| 552 | + if ( ! is_string( $target ) ) { | |
| 553 | + return false; | |
| 554 | + } | |
| 555 | + | |
| 556 | + foreach ( array( self::QUERY_VAR, self::TOKEN_QUERY_VAR, self::WELLKNOWN_QUERY_VAR, self::AUTHORIZE_QUERY_VAR, self::ATTACH_QUERY_VAR ) as $var ) { | |
| 557 | + if ( false !== strpos( $target, $var . '=' ) ) { | |
| 558 | + return true; | |
| 559 | + } | |
| 560 | + } | |
| 561 | + | |
| 562 | + return false; | |
| 563 | + } | |
| 564 | + | |
| 565 | + /** | |
| 308 | 566 | * True when a request for our discovery URL can reach WordPress at all. |
| 309 | 567 | * |
| 310 | 568 | * Since maybe_handle_pretty_endpoint() claims the document by REQUEST |
| 311 | 569 | * PATH, a sibling plugin winning the rewrite match no longer matters — |
| @@ -326,9 +584,16 @@ | ||
| 326 | 584 | |
| 327 | 585 | // Any rule that routes our discovery path to index.php will do — ours |
| 328 | 586 | // or a sibling's — because the path check inside the handler decides |
| 329 | 587 | // the outcome once the request lands. |
| 330 | - $probe = '.well-known/oauth-protected-resource'; | |
| 588 | + // | |
| 589 | + // Probe the URL the 401 challenge actually advertises: the | |
| 590 | + // path-suffixed form, which is the canonical identity since #266. | |
| 591 | + // Probing root would answer a different question — whether ANY plugin | |
| 592 | + // routes the contested URL — and on a site where a sibling owns it | |
| 593 | + // that answer says nothing about whether our own document is | |
| 594 | + // reachable. | |
| 595 | + $probe = '.well-known/oauth-protected-resource/' . Mcp_Pairing::SITE_ENDPOINT_PATH; | |
| 331 | 596 | foreach ( $rules as $pattern => $target ) { |
| 332 | 597 | if ( preg_match( '#' . str_replace( '#', '\\#', $pattern ) . '#', $probe ) ) { |
| 333 | 598 | return true; |
| 334 | 599 | } |
| @@ -353,23 +618,44 @@ | ||
| 353 | 618 | /** |
| 354 | 619 | * Which discovery document the CURRENT request path asks for, if any. |
| 355 | 620 | * |
| 356 | 621 | * 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 | |
| 622 | + * rules exactly: the RFC 9728 §3.1 / RFC 8414 §3.1 path-suffixed form | |
| 623 | + * naming our own resource and issuer (`/xspeed/mcp`). A suffix belonging | |
| 624 | + * to a sibling plugin is deliberately NOT claimed — answering | |
| 360 | 625 | * `/.well-known/oauth-protected-resource/betterlinks/mcp` with xSpeed |
| 361 | 626 | * metadata is the same bug that broke this site, just pointed the other |
| 362 | 627 | * way. |
| 363 | 628 | * |
| 364 | - * @return string 'protected-resource', 'authorization-server', or ''. | |
| 629 | + * The bare root form is claimed only while no other plugin's rewrite rule | |
| 630 | + * claims it. Leaving the suffix optional here took the root document from | |
| 631 | + * a sibling even on a build that had stopped registering its own root | |
| 632 | + * rule, so the path check is exact. | |
| 633 | + * | |
| 634 | + * The TABLE is what hands root over -- add_rewrite() stops registering | |
| 635 | + * the rule and flushes it away. This claim only releases it, and only | |
| 636 | + * while a sibling's rule actually owns the URL: once WordPress has | |
| 637 | + * routed the request to OUR query var, no other plugin's handler can see | |
| 638 | + * it, so releasing it would abandon the request to the front page rather | |
| 639 | + * than pass it on. Note that is about the winning rule's TARGET, not its | |
| 640 | + * regex -- a sibling can hold the same pattern. The document served at root carries the LEGACY host-only | |
| 641 | + * issuer, the identifier a client used to derive that URL. (#266) | |
| 642 | + * | |
| 643 | + * @param string $matched_query The query the matched rule resolved to. | |
| 644 | + * @return array{doc:string,issuer:string} Doc name ('' when not ours) | |
| 645 | + * and the identity to stamp on it. | |
| 365 | 646 | */ |
| 366 | - private function wellknown_doc_from_path(): string { | |
| 647 | + private function wellknown_claim_from_path( string $matched_query = '' ): array { | |
| 648 | + $none = array( | |
| 649 | + 'doc' => '', | |
| 650 | + 'issuer' => '', | |
| 651 | + ); | |
| 652 | + | |
| 367 | 653 | $uri = isset( $_SERVER['REQUEST_URI'] ) |
| 368 | 654 | ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) |
| 369 | 655 | : ''; |
| 370 | 656 | if ( '' === $uri ) { |
| 371 | - return ''; | |
| 657 | + return $none; | |
| 372 | 658 | } |
| 373 | 659 | |
| 374 | 660 | $path = (string) wp_parse_url( $uri, PHP_URL_PATH ); |
| 375 | 661 | |
| @@ -380,12 +666,56 @@ | ||
| 380 | 666 | } |
| 381 | 667 | |
| 382 | 668 | $path = trim( $path, '/' ); |
| 383 | 669 | |
| 384 | - $pattern = '#^\.well-known/oauth-(protected-resource|authorization-server)' | |
| 385 | - . '(?:/xspeed/mcp)?$#'; | |
| 670 | + // trim() above already dropped a trailing slash, so `/xspeed/mcp/` | |
| 671 | + // still matches — and so does the root form with one. | |
| 672 | + $ours = '#^\.well-known/oauth-(protected-resource|authorization-server)' | |
| 673 | + . '/' . preg_quote( Mcp_Pairing::SITE_ENDPOINT_PATH, '#' ) . '$#'; | |
| 674 | + if ( preg_match( $ours, $path, $m ) ) { | |
| 675 | + return array( | |
| 676 | + 'doc' => $m[1], | |
| 677 | + 'issuer' => Mcp_OAuth::issuer(), | |
| 678 | + ); | |
| 679 | + } | |
| 386 | 680 | |
| 387 | - return preg_match( $pattern, $path, $m ) ? $m[1] : ''; | |
| 681 | + // Releasing root is only safe when somebody else can pick it up. If | |
| 682 | + // OUR rule is what WordPress matched, nobody can: the sibling's query | |
| 683 | + // var is unset, so its handler never runs, and the request falls | |
| 684 | + // through to the front page -- a 301 to the homepage where dev | |
| 685 | + // returns JSON. Answering with the legacy document is the pre-#266 | |
| 686 | + // behaviour. (#266 QA) | |
| 687 | + // | |
| 688 | + // Keyed on the query the matched rule RESOLVED TO -- not on | |
| 689 | + // $wp->query_vars, and not on which regex matched. | |
| 690 | + // | |
| 691 | + // query_vars is wrong because the var is public and WP::parse_request | |
| 692 | + // lets $_GET override anything a rule set, so `?xspeed_mcp_wellknown=1` | |
| 693 | + // would let anyone force our metadata onto a URL a sibling owns. | |
| 694 | + // | |
| 695 | + // matched_rule is wrong because the rewrite table is keyed BY regex: | |
| 696 | + // a sibling that registered this same pattern replaces our entry and | |
| 697 | + // the key still reads as ours, while the target behind it is theirs. | |
| 698 | + // That is a live case here -- is_our_rule_target() exists for it -- | |
| 699 | + // and keying on the rule would answer for the sibling, which is the | |
| 700 | + // bug this whole change is about. | |
| 701 | + // | |
| 702 | + // matched_query is built from the winning rule's TARGET (class-wp.php, | |
| 703 | + // before the parse_request action) and $_GET never touches it. If it | |
| 704 | + // sets our query var, our rule genuinely won. (#266 QA) | |
| 705 | + $routed_to_us = 1 === preg_match( | |
| 706 | + '#(?:^|&)' . preg_quote( self::WELLKNOWN_QUERY_VAR, '#' ) . '=#', | |
| 707 | + $matched_query | |
| 708 | + ); | |
| 709 | + $root = '#^\.well-known/oauth-(protected-resource|authorization-server)$#'; | |
| 710 | + if ( preg_match( $root, $path, $m ) && ( $routed_to_us || ! self::root_discovery_contested() ) ) { | |
| 711 | + return array( | |
| 712 | + 'doc' => $m[1], | |
| 713 | + 'issuer' => Mcp_OAuth::legacy_issuer(), | |
| 714 | + ); | |
| 715 | + } | |
| 716 | + | |
| 717 | + return $none; | |
| 388 | 718 | } |
| 389 | 719 | |
| 390 | 720 | /** |
| 391 | 721 | * Serve the MCP endpoint on the pretty path. Runs on parse_request so |
| @@ -393,11 +723,11 @@ | ||
| 393 | 723 | * |
| 394 | 724 | * @param \WP $wp The WP request object. |
| 395 | 725 | */ |
| 396 | 726 | public function maybe_handle_pretty_endpoint( $wp ): void { |
| 397 | - // OAuth discovery documents (served at the site root). | |
| 727 | + // OAuth discovery documents. | |
| 398 | 728 | // |
| 399 | - // Read the doc name from the REQUEST PATH, not just our query var. | |
| 729 | + // Read the doc name from the REQUEST PATH, and ONLY from the path. | |
| 400 | 730 | // `add_rewrite_rule( …, 'top' )` only means "top at the moment it |
| 401 | 731 | // runs", so whichever MCP plugin hooks `init` last ends up first in |
| 402 | 732 | // the table — an order set by plugin load order, which no plugin |
| 403 | 733 | // controls. A sibling's catch-all |
| @@ -405,20 +735,28 @@ | ||
| 405 | 735 | // the match and our query var is never set, even though the URL is |
| 406 | 736 | // unambiguously ours. Observed live with two different plugins on one |
| 407 | 737 | // site. parse_request runs AFTER matching, so the path is the one |
| 408 | 738 | // 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 | - } | |
| 739 | + // | |
| 740 | + // The query VAR is deliberately never consulted. It is public, so | |
| 741 | + // $_GET can set it on any URL, and answering from it would put our | |
| 742 | + // metadata on somebody else's address — the whole bug. Which rewrite | |
| 743 | + // RULE matched is a different thing: WordPress decides it, the query | |
| 744 | + // string cannot influence it, and it is only read to tell "a sibling | |
| 745 | + // owns this URL" from "we own it and nobody else can answer". | |
| 746 | + $matched_query = is_object( $wp ) && isset( $wp->matched_query ) ? (string) $wp->matched_query : ''; | |
| 747 | + $claim = $this->wellknown_claim_from_path( $matched_query ); | |
| 748 | + $doc = $claim['doc']; | |
| 413 | 749 | if ( '' !== $doc ) { |
| 414 | 750 | $data = 'authorization-server' === $doc |
| 415 | - ? Mcp_OAuth::authorization_server_metadata() | |
| 416 | - : Mcp_OAuth::protected_resource_metadata(); | |
| 751 | + ? Mcp_OAuth::authorization_server_metadata( $claim['issuer'] ) | |
| 752 | + : Mcp_OAuth::protected_resource_metadata( $claim['issuer'] ); | |
| 417 | 753 | status_header( 200 ); |
| 418 | 754 | header( 'Content-Type: application/json; charset=utf-8' ); |
| 419 | - // Discovery metadata is public + cacheable. | |
| 420 | - header( 'Cache-Control: public, max-age=3600' ); | |
| 755 | + // Public and cacheable, but short: this document IS the server's | |
| 756 | + // identity, and a cached copy outliving an issuer change is the | |
| 757 | + // one failure a client cannot recover from on its own. (#266) | |
| 758 | + header( 'Cache-Control: public, max-age=300' ); | |
| 421 | 759 | echo wp_json_encode( $data ); |
| 422 | 760 | exit; |
| 423 | 761 | } |
| 424 | 762 | |
| @@ -434,8 +772,10 @@ | ||
| 434 | 772 | if ( null === $result ) { |
| 435 | 773 | status_header( 403 ); |
| 436 | 774 | echo wp_json_encode( array( 'error' => 'invalid_or_expired_attach_request' ) ); |
| 437 | 775 | } else { |
| 776 | + // The Hub needs only the credential, as on the REST route. | |
| 777 | + unset( $result['user_id'] ); | |
| 438 | 778 | status_header( 200 ); |
| 439 | 779 | echo wp_json_encode( $result ); |
| 440 | 780 | } |
| 441 | 781 | exit; |
| @@ -1024,9 +1364,11 @@ | ||
| 1024 | 1364 | * @return \WP_REST_Response |
| 1025 | 1365 | */ |
| 1026 | 1366 | private function discovery_response( array $data ): \WP_REST_Response { |
| 1027 | 1367 | $response = new \WP_REST_Response( $data, 200 ); |
| 1028 | - $response->header( 'Cache-Control', 'public, max-age=3600' ); | |
| 1368 | + // Same short window as the /.well-known/ emit site, same reason: the | |
| 1369 | + // document carries the server's identity. (#266) | |
| 1370 | + $response->header( 'Cache-Control', 'public, max-age=300' ); | |
| 1029 | 1371 | return $response; |
| 1030 | 1372 | } |
| 1031 | 1373 | |
| 1032 | 1374 | /** |
| @@ -1145,8 +1487,61 @@ | ||
| 1145 | 1487 | return $response; |
| 1146 | 1488 | } |
| 1147 | 1489 | |
| 1148 | 1490 | /** |
| 1491 | + * Reject an over-sized MCP request body before WordPress decodes it. | |
| 1492 | + * | |
| 1493 | + * `rest_pre_dispatch` is the last hook that runs before | |
| 1494 | + * `WP_REST_Server::dispatch()` calls `has_valid_params()`, and that is | |
| 1495 | + * what `json_decode()`s the body — before the permission callback, so an | |
| 1496 | + * unauthenticated caller already pays for the decode. Capping here is the | |
| 1497 | + * difference between reading a length and parsing megabytes of JSON. | |
| 1498 | + * | |
| 1499 | + * Refusing is not enough on its own. Core reads request params again on | |
| 1500 | + * the way out — `rest_filter_response_fields()` on `rest_post_dispatch` | |
| 1501 | + * looks up `_fields` — and for an `application/json` request that lookup | |
| 1502 | + * runs `parse_json_params()` over whatever body is still attached. So a | |
| 1503 | + * refused body is also emptied, and the 413 goes out with nothing left to | |
| 1504 | + * decode. QA measured a refused 3 MB body peaking near 90 MB without this. | |
| 1505 | + * | |
| 1506 | + * Scoped to the two routes that carry tool payloads, compared | |
| 1507 | + * case-insensitively: `WP_REST_Server::match_request_to_handler()` matches | |
| 1508 | + * routes with the `i` flag, so `/XSPEED/v1/MCP` reaches the same handler | |
| 1509 | + * and has to meet the same cap. Returning null leaves the request alone, | |
| 1510 | + * which is what this filter does for everything else. | |
| 1511 | + * | |
| 1512 | + * @param mixed $result A short-circuit response, if one is set. | |
| 1513 | + * @param mixed $server Unused; the REST server instance. | |
| 1514 | + * @param \WP_REST_Request $request Incoming request. | |
| 1515 | + * @return mixed Null to continue, or a WP_Error to refuse. | |
| 1516 | + */ | |
| 1517 | + public function cap_request_body( $result, $server = null, $request = null ) { | |
| 1518 | + if ( null !== $result || ! $request instanceof \WP_REST_Request ) { | |
| 1519 | + return $result; | |
| 1520 | + } | |
| 1521 | + | |
| 1522 | + $route = strtolower( (string) $request->get_route() ); | |
| 1523 | + $mcp = '/' . self::NS . '/mcp'; | |
| 1524 | + if ( $route !== $mcp && 0 !== strpos( $route, $mcp . '/tool/' ) ) { | |
| 1525 | + return $result; | |
| 1526 | + } | |
| 1527 | + | |
| 1528 | + if ( strlen( (string) $request->get_body() ) <= Mcp_Tools::MAX_TOOL_BODY_BYTES ) { | |
| 1529 | + return $result; | |
| 1530 | + } | |
| 1531 | + | |
| 1532 | + // Drop the body before refusing. Nothing downstream needs it, and | |
| 1533 | + // core's response pipeline would otherwise json_decode() it anyway. | |
| 1534 | + $request->set_body( '' ); | |
| 1535 | + | |
| 1536 | + return new \WP_Error( | |
| 1537 | + 'xspeed_mcp_tool_payload_too_large', | |
| 1538 | + __( 'The MCP tool payload is too large.', 'xspeed' ), | |
| 1539 | + array( 'status' => 413 ) | |
| 1540 | + ); | |
| 1541 | + } | |
| 1542 | + | |
| 1543 | + /** | |
| 1149 | 1544 | * Token-authenticated tool route for the hosted broker. Maps a broker |
| 1150 | 1545 | * tool call (e.g. GET /mcp/tool/get_cache_status) onto the shared |
| 1151 | 1546 | * Mcp_Tools catalog, so the broker path and the JSON-RPC path never |
| 1152 | 1547 | * drift. GET params + JSON body both feed the tool's arguments. |
| @@ -1152,8 +1547,18 @@ | ||
| 1152 | 1547 | * drift. GET params + JSON body both feed the tool's arguments. |
| 1153 | 1548 | */ |
| 1154 | 1549 | public function rest_tool( \WP_REST_Request $request ) { |
| 1155 | 1550 | $tool = (string) $request->get_param( 'tool' ); |
| 1551 | + // Second line behind cap_request_body(). This one still matters: the | |
| 1552 | + // pretty front-door path builds its own WP_REST_Request and calls the | |
| 1553 | + // handler without going through WP_REST_Server::dispatch() at all. | |
| 1554 | + if ( strlen( $request->get_body() ) > Mcp_Tools::MAX_TOOL_BODY_BYTES ) { | |
| 1555 | + return new \WP_Error( | |
| 1556 | + 'xspeed_mcp_tool_payload_too_large', | |
| 1557 | + __( 'The MCP tool payload is too large.', 'xspeed' ), | |
| 1558 | + array( 'status' => 413 ) | |
| 1559 | + ); | |
| 1560 | + } | |
| 1156 | 1561 | $args = $request->get_json_params(); |
| 1157 | 1562 | if ( ! is_array( $args ) ) { |
| 1158 | 1563 | $args = array(); |
| 1159 | 1564 | } |