| @@ -53,8 +53,34 @@ | ||
| 53 | 53 | */ |
| 54 | 54 | private const QUERY_VAR = 'thinkrank_mcp'; |
| 55 | 55 | |
| 56 | 56 | /** |
| 57 | + * Whether this request is an MCP call being served. | |
| 58 | + * | |
| 59 | + * The pretty /thinkrank/mcp route is dispatched from `parse_request`, so it | |
| 60 | + * is neither `is_admin()` nor `REST_REQUEST`. Anything deciding whether a | |
| 61 | + * request may take on deferred work needs to be able to see it (#764). | |
| 62 | + * | |
| 63 | + * @since 2.10.0 | |
| 64 | + * @var bool | |
| 65 | + */ | |
| 66 | + private static $serving_request = false; | |
| 67 | + | |
| 68 | + /** | |
| 69 | + * Whether an MCP call is being served on this request. | |
| 70 | + * | |
| 71 | + * Pair with `is_user_logged_in()` to mean "an authenticated MCP call": | |
| 72 | + * Mcp_Server sets the current user only once a credential has validated, so | |
| 73 | + * an unauthenticated POST never looks like one. | |
| 74 | + * | |
| 75 | + * @since 2.10.0 | |
| 76 | + * @return bool | |
| 77 | + */ | |
| 78 | + public static function is_serving_request(): bool { | |
| 79 | + return self::$serving_request; | |
| 80 | + } | |
| 81 | + | |
| 82 | + /** | |
| 57 | 83 | * Query var carrying the token when embedded in the URL path. |
| 58 | 84 | */ |
| 59 | 85 | private const TOKEN_QUERY_VAR = 'thinkrank_mcp_token'; |
| 60 | 86 | |
| @@ -63,8 +89,14 @@ | ||
| 63 | 89 | */ |
| 64 | 90 | private const WELLKNOWN_QUERY_VAR = 'thinkrank_mcp_wellknown'; |
| 65 | 91 | |
| 66 | 92 | /** |
| 93 | + * Query var carrying the resource path a root-form discovery request asked | |
| 94 | + * about, so the handler can tell whether that resource is ours (#516). | |
| 95 | + */ | |
| 96 | + private const WELLKNOWN_RESOURCE_QUERY_VAR = 'thinkrank_mcp_wellknown_resource'; | |
| 97 | + | |
| 98 | + /** | |
| 67 | 99 | * Query var flagging the browser-facing OAuth authorize page. This is |
| 68 | 100 | * served OUTSIDE the REST API on purpose: a REST route only honors cookie |
| 69 | 101 | * auth when a REST nonce accompanies it, but a browser arriving from |
| 70 | 102 | * wp-login carries the cookie with NO nonce — so is_user_logged_in() |
| @@ -81,15 +113,56 @@ | ||
| 81 | 113 | */ |
| 82 | 114 | public function init(): void { |
| 83 | 115 | add_action( 'rest_api_init', [ $this, 'register_rest' ] ); |
| 84 | 116 | |
| 117 | + // Published /.well-known/ files are served ahead of WordPress, so a | |
| 118 | + // site URL change leaves them advertising the old domain's issuer with | |
| 119 | + // nothing to correct them. Registered unconditionally: a stale | |
| 120 | + // document is harmful whether or not MCP is currently enabled (#486). | |
| 121 | + Mcp_Static_Discovery::init(); | |
| 122 | + | |
| 85 | 123 | // Pretty per-site endpoint: /thinkrank/mcp → MCP JSON-RPC handler. |
| 86 | 124 | add_action( 'init', [ $this, 'add_rewrite' ] ); |
| 87 | 125 | add_filter( 'query_vars', [ $this, 'register_query_var' ] ); |
| 88 | 126 | add_action( 'parse_request', [ $this, 'maybe_handle_pretty_endpoint' ] ); |
| 127 | + | |
| 128 | + // The one broken state the server can't see from inside a request: | |
| 129 | + // MCP enabled but the bundled Abilities runtime absent. Everything | |
| 130 | + // else still works — OAuth discovers, tokens mint, clients connect — | |
| 131 | + // and tools/list is an empty array served as success. Three layers | |
| 132 | + // each "no-op gracefully" (the is_readable() require, the registrar, | |
| 133 | + // the tool registry) and composed they manufacture a connector that | |
| 134 | + // connects and offers nothing, with no signal anywhere. Say it loudly | |
| 135 | + // where an admin will look. | |
| 136 | + add_action( 'admin_notices', [ $this, 'warn_when_runtime_missing' ] ); | |
| 89 | 137 | } |
| 90 | 138 | |
| 91 | 139 | /** |
| 140 | + * Admin notice when MCP is enabled but the Abilities runtime is missing. | |
| 141 | + * | |
| 142 | + * That combination almost always means an incomplete package — a source | |
| 143 | + * archive or a zip built without `dependencies/vendor/` (the bundled | |
| 144 | + * Abilities API + MCP adapter). Shown to admins on every screen: the fix | |
| 145 | + * is reinstalling the plugin, and a user mid-support-ticket needs to see | |
| 146 | + * it without knowing which screen to visit. | |
| 147 | + * | |
| 148 | + * @return void | |
| 149 | + */ | |
| 150 | + public function warn_when_runtime_missing(): void { | |
| 151 | + if ( ! self::is_enabled() || function_exists( 'wp_register_ability' ) ) { | |
| 152 | + return; | |
| 153 | + } | |
| 154 | + if ( ! current_user_can( 'manage_options' ) ) { | |
| 155 | + return; | |
| 156 | + } | |
| 157 | + printf( | |
| 158 | + '<div class="notice notice-error"><p><strong>%s</strong> %s</p></div>', | |
| 159 | + esc_html__( 'ThinkRank MCP: AI assistants will connect but see no tools.', 'thinkrank' ), | |
| 160 | + esc_html__( 'MCP access is enabled, but the bundled Abilities runtime (dependencies/vendor) is missing from this installation — usually a plugin package built without it. Reinstall ThinkRank from wordpress.org or an official build; until then, connected AI clients get an empty tool list.', 'thinkrank' ) | |
| 161 | + ); | |
| 162 | + } | |
| 163 | + | |
| 164 | + /** | |
| 92 | 165 | * Whether the MCP integration is enabled via the admin setting. |
| 93 | 166 | * |
| 94 | 167 | * @return bool |
| 95 | 168 | */ |
| @@ -105,69 +178,197 @@ | ||
| 105 | 178 | * |
| 106 | 179 | * @return void |
| 107 | 180 | */ |
| 108 | 181 | public function add_rewrite(): void { |
| 109 | - // Token-in-URL form: /thinkrank/mcp/<token> — a single string the user | |
| 110 | - // pastes into their AI client (no separate token field). The bare | |
| 111 | - // /thinkrank/mcp still works with a Bearer token. | |
| 112 | - add_rewrite_rule( | |
| 113 | - '^thinkrank/mcp/([a-f0-9]{64})/?$', | |
| 114 | - 'index.php?' . self::QUERY_VAR . '=1&' . self::TOKEN_QUERY_VAR . '=$matches[1]', | |
| 115 | - 'top' | |
| 116 | - ); | |
| 117 | - add_rewrite_rule( '^thinkrank/mcp/?$', 'index.php?' . self::QUERY_VAR . '=1', 'top' ); | |
| 182 | + $rules = self::rewrite_rules(); | |
| 118 | 183 | |
| 119 | - // OAuth discovery documents. RFC 9728 §3.1 / RFC 8414 §3.1 place the | |
| 120 | - // `.well-known` segment BEFORE the resource path, so our resource at | |
| 121 | - // /thinkrank/mcp is discovered at the path-suffixed form: | |
| 122 | - // /.well-known/oauth-protected-resource/thinkrank/mcp | |
| 123 | - // /.well-known/oauth-authorization-server/thinkrank/mcp | |
| 124 | - // The OAuth issuer is the path-based identifier home_url('/thinkrank/mcp') | |
| 125 | - // (see Mcp_OAuth::issuer), so spec-compliant clients derive exactly | |
| 126 | - // these URLs — and the rule stays specific to OUR path. That matters | |
| 127 | - // for coexistence: another plugin serving its own MCP OAuth surface | |
| 128 | - // (e.g. xSpeed) claims the generic `(?:/.*)?` root rule, and rewrite | |
| 129 | - // rules are keyed by regex, so a shared broad rule would be silently | |
| 130 | - // overwritten by whichever plugin registers last. | |
| 131 | - add_rewrite_rule( | |
| 132 | - '^\.well-known/oauth-(protected-resource|authorization-server)/thinkrank/mcp/?$', | |
| 133 | - 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]', | |
| 134 | - 'top' | |
| 135 | - ); | |
| 136 | - // Root-form fallback for clients that only try the bare well-known | |
| 137 | - // URL. Harmless when another plugin also registers this exact regex — | |
| 138 | - // last registrant wins, and our clients use the path-suffixed form. | |
| 139 | - add_rewrite_rule( | |
| 140 | - '^\.well-known/oauth-(protected-resource|authorization-server)(?:/.*)?/?$', | |
| 141 | - 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]', | |
| 142 | - 'top' | |
| 143 | - ); | |
| 184 | + foreach ( $rules as $regex => $query ) { | |
| 185 | + add_rewrite_rule( $regex, $query, 'top' ); | |
| 186 | + } | |
| 144 | 187 | |
| 145 | - // Browser-facing OAuth consent page — served OUTSIDE REST so cookie | |
| 146 | - // auth (is_user_logged_in) works after the wp-login round-trip. | |
| 147 | - add_rewrite_rule( '^thinkrank/authorize/?$', 'index.php?' . self::AUTHORIZE_QUERY_VAR . '=1', 'top' ); | |
| 148 | - | |
| 149 | 188 | // Self-heal: flush once if ANY of our rules is missing from the stored |
| 150 | 189 | // rewrite table, so the endpoints work without a manual permalink |
| 151 | 190 | // re-save (and newly added rules trigger a re-flush on upgrade). |
| 152 | - $expected = [ | |
| 153 | - '^thinkrank/mcp/([a-f0-9]{64})/?$', | |
| 154 | - '^thinkrank/mcp/?$', | |
| 155 | - '^\.well-known/oauth-(protected-resource|authorization-server)/thinkrank/mcp/?$', | |
| 156 | - '^thinkrank/authorize/?$', | |
| 191 | + // | |
| 192 | + // Checked against the same array that was just registered, never | |
| 193 | + // against a second hand-maintained list. The two drifted apart once | |
| 194 | + // already: #775's first pass changed the discovery regex and left the | |
| 195 | + // superseded one in the list, so a rule that is never registered was | |
| 196 | + // permanently "missing" and every front-end request rebuilt the whole | |
| 197 | + // rewrite table. Measured at 4 regenerations across 3 page loads | |
| 198 | + // against 0 before the change — silent, because a rebuilt table is | |
| 199 | + // still a correct one. | |
| 200 | + // | |
| 201 | + // It also has to notice the opposite: a rule of ours that is stored | |
| 202 | + // but no longer wanted. The bare discovery rule is registered only | |
| 203 | + // while thinkrank_mcp_serve_root_discovery is on, and "missing" alone | |
| 204 | + // never fires when it is switched off again, because every rule still | |
| 205 | + // registered is present. The stale rule then kept claiming the bare | |
| 206 | + // URL from other MCP plugins (#775) until someone re-saved | |
| 207 | + // permalinks. The unwanted set is derived from the same table built | |
| 208 | + // with every optional rule on, not listed, so it cannot drift either. | |
| 209 | + // A flush rebuilds the table from what this request registered, so | |
| 210 | + // the stale rule is gone afterwards and the check settles instead of | |
| 211 | + // flushing on every request. | |
| 212 | + $stored = get_option( 'rewrite_rules' ); | |
| 213 | + if ( is_array( $stored ) ) { | |
| 214 | + $unwanted = array_diff_key( self::rewrite_rules( true ), $rules ); | |
| 215 | + $stale = array_intersect_key( $unwanted, $stored ); | |
| 216 | + $missing = array_diff_key( $rules, $stored ); | |
| 217 | + | |
| 218 | + if ( ! empty( $missing ) || ! empty( $stale ) ) { | |
| 219 | + flush_rewrite_rules( false ); | |
| 220 | + } | |
| 221 | + } | |
| 222 | + } | |
| 223 | + | |
| 224 | + /** | |
| 225 | + * Every rewrite rule this plugin owns, as regex => query string. | |
| 226 | + * | |
| 227 | + * Single source of truth for registration AND for the self-heal check, so | |
| 228 | + * the two cannot describe different sets of rules. All of them register at | |
| 229 | + * `'top'`; a `'bottom'` rule would never be reached, because core's own | |
| 230 | + * pagename rule matches almost any path ahead of it. | |
| 231 | + * | |
| 232 | + * @since 2.10.0 | |
| 233 | + * | |
| 234 | + * @param bool|null $root_discovery Include the opt-in bare discovery rule. | |
| 235 | + * Null (the default) follows | |
| 236 | + * {@see self::serves_root_discovery()}; | |
| 237 | + * true is how add_rewrite() learns which | |
| 238 | + * optional rules exist, to retire one | |
| 239 | + * that was switched off. | |
| 240 | + * @return array<string,string> Regex => query string. | |
| 241 | + */ | |
| 242 | + private static function rewrite_rules( ?bool $root_discovery = null ): array { | |
| 243 | + $endpoint = preg_quote( trim( Mcp_Pairing::SITE_ENDPOINT_PATH, '/' ), '/' ); | |
| 244 | + | |
| 245 | + $rules = [ | |
| 246 | + // Token-in-URL form: /thinkrank/mcp/<token> — a single string the | |
| 247 | + // user pastes into their AI client (no separate token field). The | |
| 248 | + // bare /thinkrank/mcp still works with a Bearer token. | |
| 249 | + '^thinkrank/mcp/([a-f0-9]{64})/?$' => | |
| 250 | + 'index.php?' . self::QUERY_VAR . '=1&' . self::TOKEN_QUERY_VAR . '=$matches[1]', | |
| 251 | + '^thinkrank/mcp/?$' => 'index.php?' . self::QUERY_VAR . '=1', | |
| 252 | + | |
| 253 | + // OAuth discovery documents. RFC 9728 §3.1 / RFC 8414 §3.1 place | |
| 254 | + // the `.well-known` segment BEFORE the resource path, so our | |
| 255 | + // resource at /thinkrank/mcp is discovered at: | |
| 256 | + // /.well-known/oauth-protected-resource/thinkrank/mcp | |
| 257 | + // /.well-known/oauth-authorization-server/thinkrank/mcp | |
| 258 | + // The OAuth issuer is the path-based identifier | |
| 259 | + // home_url('/thinkrank/mcp') (see Mcp_OAuth::issuer), so | |
| 260 | + // spec-compliant clients derive exactly these URLs. | |
| 261 | + '^\.well-known/oauth-(protected-resource|authorization-server)/thinkrank/mcp/?$' => | |
| 262 | + 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]', | |
| 263 | + | |
| 264 | + // The same form with a site prefix in front of our endpoint path, | |
| 265 | + // which is how subdirectory multisite discovers /ca/thinkrank/mcp | |
| 266 | + // (#516). The trailing path is CAPTURED rather than discarded and | |
| 267 | + // the pattern only accepts a path ENDING in our own endpoint: an | |
| 268 | + // earlier version matched `(/.*)?` — anything at all — and so | |
| 269 | + // claimed every neighbouring MCP plugin's RFC 9728 discovery URL | |
| 270 | + // alongside our own (#775). | |
| 271 | + // | |
| 272 | + // Declining inside the handler does not undo that, and this is the | |
| 273 | + // part worth being precise about: rewrite matching happens once, in | |
| 274 | + // WP::parse_request(), before the `parse_request` action our | |
| 275 | + // handler runs on. By then this rule has already won and the owning | |
| 276 | + // plugin's rule has not matched, so its query var is never set and | |
| 277 | + // its handler never fires. Returning instead of exiting would leave | |
| 278 | + // the request to die in the main query — a 404 either way, exactly | |
| 279 | + // as broken for the neighbour as serving them our document was. The | |
| 280 | + // rule itself has to stop matching, so normal rewrite resolution | |
| 281 | + // reaches the plugin that owns the URL. | |
| 282 | + // | |
| 283 | + // The handler still validates what this does match: a prefix that | |
| 284 | + // is not a real site on this network is declined there. | |
| 285 | + '^\.well-known/oauth-(protected-resource|authorization-server)((?:/.*)?/' . $endpoint . ')/?$' => | |
| 286 | + 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]&' . self::WELLKNOWN_RESOURCE_QUERY_VAR . '=$matches[2]', | |
| 287 | + | |
| 288 | + // Suffix form: <issuer>/.well-known/... . RFC 8414 specifies the | |
| 289 | + // path-INSERT form above, but the older OpenID Connect Discovery | |
| 290 | + // convention appends instead, and clients built on an OIDC library | |
| 291 | + // try that shape first (sometimes only that shape). Both of these | |
| 292 | + // live under our own endpoint path, so neither can collide with | |
| 293 | + // another plugin's discovery URLs. | |
| 294 | + '^thinkrank/mcp/\.well-known/oauth-(protected-resource|authorization-server)/?$' => | |
| 295 | + 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]', | |
| 296 | + '^thinkrank/mcp/\.well-known/openid-configuration/?$' => | |
| 297 | + 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=authorization-server', | |
| 298 | + | |
| 299 | + // Browser-facing OAuth consent page — served OUTSIDE REST so cookie | |
| 300 | + // auth (is_user_logged_in) works after the wp-login round-trip. | |
| 301 | + '^thinkrank/authorize/?$' => 'index.php?' . self::AUTHORIZE_QUERY_VAR . '=1', | |
| 157 | 302 | ]; |
| 158 | - $rules = get_option( 'rewrite_rules' ); | |
| 159 | - if ( is_array( $rules ) ) { | |
| 160 | - foreach ( $expected as $rule ) { | |
| 161 | - if ( ! isset( $rules[ $rule ] ) ) { | |
| 162 | - flush_rewrite_rules( false ); | |
| 163 | - break; | |
| 164 | - } | |
| 165 | - } | |
| 303 | + | |
| 304 | + if ( $root_discovery ?? self::serves_root_discovery() ) { | |
| 305 | + $rules['^\.well-known/oauth-(protected-resource|authorization-server)/?$'] = | |
| 306 | + 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]'; | |
| 166 | 307 | } |
| 308 | + | |
| 309 | + return $rules; | |
| 167 | 310 | } |
| 168 | 311 | |
| 169 | 312 | /** |
| 313 | + * Whether to answer the BARE `/.well-known/oauth-*` URLs, with no resource | |
| 314 | + * path after the document name. Off by default. | |
| 315 | + * | |
| 316 | + * That URL is not ours to answer, in two separate senses. | |
| 317 | + * | |
| 318 | + * It is not ours by the specs. RFC 9728 §3.1 builds a resource's metadata | |
| 319 | + * URL by inserting `.well-known` before the resource's path, so the bare | |
| 320 | + * form is the metadata URL for the resource `https://example.com` — the | |
| 321 | + * site root. Ours is `https://example.com/thinkrank/mcp`. §3.3 requires the | |
| 322 | + * `resource` in the document to equal the identifier it was fetched for, so | |
| 323 | + * answering there returns a document that contradicts its own URL. RFC 8414 | |
| 324 | + * §3.3 says the same of `issuer`. A client that validates is entitled to | |
| 325 | + * reject it; the only clients it ever helped were those that do not. | |
| 326 | + * | |
| 327 | + * And it is not ours on a site with a second MCP plugin. Registered at | |
| 328 | + * `'top'`, our rule did not merely enter the ordering race for that URL, it | |
| 329 | + * went to the front of it — so a neighbour's client reading the bare form | |
| 330 | + * got OUR resource identifier while connecting somewhere else, which is the | |
| 331 | + * same RFC 9728 mismatch #775 was filed about, reached by the other URL. | |
| 332 | + * Because the winner depends on registration and flush order, it also | |
| 333 | + * reproduced intermittently: the same site could behave differently after | |
| 334 | + * an unrelated flush. | |
| 335 | + * | |
| 336 | + * Nothing of ours needs it. The 401 challenge advertises | |
| 337 | + * {@see Mcp_OAuth::resource_metadata_url()}, a route in our own REST | |
| 338 | + * namespace that no other plugin can claim; the path-insert and | |
| 339 | + * OIDC-suffix forms above cover spec-compliant and OIDC-library clients; | |
| 340 | + * {@see Mcp_Self_Test} probes none of the bare URLs; and | |
| 341 | + * {@see Mcp_Static_Discovery} publishes only the path-suffixed files. | |
| 342 | + * | |
| 343 | + * What it did cover is a client that derives the metadata URL from the host | |
| 344 | + * alone, dropping the path. On a site where ThinkRank is the only MCP | |
| 345 | + * plugin that client worked, and this filter is how such a site keeps it | |
| 346 | + * working. It is opt-in because switching it on is a claim over a shared | |
| 347 | + * URL, and only the site owner knows whether anything else wants it. | |
| 348 | + * | |
| 349 | + * @since 2.10.0 | |
| 350 | + * | |
| 351 | + * @return bool | |
| 352 | + */ | |
| 353 | + public static function serves_root_discovery(): bool { | |
| 354 | + /** | |
| 355 | + * Filter whether ThinkRank answers the bare `/.well-known/oauth-*` | |
| 356 | + * discovery URLs, which carry no resource path. | |
| 357 | + * | |
| 358 | + * Off by default: the URL identifies the site root rather than our MCP | |
| 359 | + * endpoint, and claiming it breaks any other MCP plugin on the site. | |
| 360 | + * Switch it on only where ThinkRank is the sole MCP provider and a | |
| 361 | + * client derives the metadata URL from the host without the path. | |
| 362 | + * | |
| 363 | + * @since 2.10.0 | |
| 364 | + * | |
| 365 | + * @param bool $serve Whether to register the bare discovery rules. | |
| 366 | + */ | |
| 367 | + return (bool) apply_filters( 'thinkrank_mcp_serve_root_discovery', false ); | |
| 368 | + } | |
| 369 | + | |
| 370 | + /** | |
| 170 | 371 | * Register our query vars. |
| 171 | 372 | * |
| 172 | 373 | * @param string[] $vars Registered query vars. |
| 173 | 374 | * @return string[] |
| @@ -175,13 +376,174 @@ | ||
| 175 | 376 | public function register_query_var( array $vars ): array { |
| 176 | 377 | $vars[] = self::QUERY_VAR; |
| 177 | 378 | $vars[] = self::TOKEN_QUERY_VAR; |
| 178 | 379 | $vars[] = self::WELLKNOWN_QUERY_VAR; |
| 380 | + $vars[] = self::WELLKNOWN_RESOURCE_QUERY_VAR; | |
| 179 | 381 | $vars[] = self::AUTHORIZE_QUERY_VAR; |
| 180 | 382 | return $vars; |
| 181 | 383 | } |
| 182 | 384 | |
| 183 | 385 | /** |
| 386 | + * Resolve a root-form discovery request to the site that owns the resource. | |
| 387 | + * | |
| 388 | + * The path-suffixed and issuer-suffixed rules are already pinned to | |
| 389 | + * `thinkrank/mcp`, so only the broad root-form rule can arrive here naming | |
| 390 | + * something else. Three outcomes: | |
| 391 | + * | |
| 392 | + * - `0` — serve from the current site. That covers the bare form | |
| 393 | + * (`/.well-known/oauth-authorization-server`, the whole reason the | |
| 394 | + * fallback rule exists) and this site's own endpoint path. | |
| 395 | + * - a blog id — a subdirectory multisite request for another site's | |
| 396 | + * resource. On subdirectory multisite everything under the network root | |
| 397 | + * is served by the MAIN site, so a client discovering | |
| 398 | + * `/ca/thinkrank/mcp` lands here; the document has to be built from the | |
| 399 | + * `/ca/` site or every endpoint in it loses the prefix. | |
| 400 | + * - `null` — not ours. Another plugin's resource, an unknown site path, or | |
| 401 | + * a path that merely contains ours. | |
| 402 | + * | |
| 403 | + * @since 2.9.0 | |
| 404 | + * | |
| 405 | + * @param \WP $wp The WP request object. | |
| 406 | + * @return int|null Blog id to serve from, 0 for the current site, null to decline. | |
| 407 | + */ | |
| 408 | + private static function resolve_wellknown_target( $wp ): ?int { | |
| 409 | + $requested = isset( $wp->query_vars[ self::WELLKNOWN_RESOURCE_QUERY_VAR ] ) | |
| 410 | + ? trim( (string) $wp->query_vars[ self::WELLKNOWN_RESOURCE_QUERY_VAR ], '/' ) | |
| 411 | + : ''; | |
| 412 | + | |
| 413 | + $ours = trim( Mcp_Pairing::SITE_ENDPOINT_PATH, '/' ); | |
| 414 | + | |
| 415 | + if ( '' === $requested || $requested === $ours ) { | |
| 416 | + return 0; | |
| 417 | + } | |
| 418 | + | |
| 419 | + if ( ! is_multisite() || ! function_exists( 'get_site_by_path' ) ) { | |
| 420 | + return null; | |
| 421 | + } | |
| 422 | + | |
| 423 | + // Whatever precedes our endpoint path is the candidate site path: | |
| 424 | + // `ca/thinkrank/mcp` -> `/ca/`. Matching the tail as a whole path | |
| 425 | + // segment, not a substring, so `thinkrank/mcp-other` cannot qualify. | |
| 426 | + $candidate = '/' . $requested; | |
| 427 | + $suffix = '/' . $ours; | |
| 428 | + | |
| 429 | + if ( substr( $candidate, - strlen( $suffix ) ) !== $suffix ) { | |
| 430 | + return null; | |
| 431 | + } | |
| 432 | + | |
| 433 | + $site_path = substr( $candidate, 0, - strlen( $ours ) ); | |
| 434 | + $domain = self::request_domain(); | |
| 435 | + | |
| 436 | + if ( '' === $domain || '' === $site_path ) { | |
| 437 | + return null; | |
| 438 | + } | |
| 439 | + | |
| 440 | + $site = get_site_by_path( $domain, $site_path ); | |
| 441 | + | |
| 442 | + if ( ! $site ) { | |
| 443 | + return null; | |
| 444 | + } | |
| 445 | + | |
| 446 | + // get_site_by_path() walks the path segments and falls back to the | |
| 447 | + // network's root site when none match, so an unknown prefix comes back | |
| 448 | + // as the MAIN site rather than as nothing. Taking that at face value | |
| 449 | + // reinstates the exact bug for every path that is not a real subsite: | |
| 450 | + // /nope/thinkrank/mcp would be answered with the main site's document. | |
| 451 | + // Require the match to be the path that was actually asked for. | |
| 452 | + if ( untrailingslashit( (string) $site->path ) !== untrailingslashit( $site_path ) ) { | |
| 453 | + return null; | |
| 454 | + } | |
| 455 | + | |
| 456 | + // get_sites() applies no status filter, so a site the network has taken | |
| 457 | + // out of service resolves like any other. Advertising an authorization | |
| 458 | + // server for one would point a client at an endpoint that cannot serve | |
| 459 | + // it. `public` is deliberately NOT checked: on multisite that flag is | |
| 460 | + // search-engine visibility, not availability, and a site can reasonably | |
| 461 | + // be hidden from search while still running MCP. | |
| 462 | + if ( ! empty( $site->archived ) || ! empty( $site->deleted ) || ! empty( $site->spam ) ) { | |
| 463 | + return null; | |
| 464 | + } | |
| 465 | + | |
| 466 | + return (int) $site->blog_id === get_current_blog_id() ? 0 : (int) $site->blog_id; | |
| 467 | + } | |
| 468 | + | |
| 469 | + /** | |
| 470 | + * Host for a `get_site_by_path()` lookup. | |
| 471 | + * | |
| 472 | + * Mirrors what WordPress itself stores in `wp_blogs`: core strips only the | |
| 473 | + * default ports when it resolves the current site, so a development network | |
| 474 | + * running on a non-default port keeps it, and stripping every port here | |
| 475 | + * would fail to match those rows. | |
| 476 | + * | |
| 477 | + * @since 2.9.0 | |
| 478 | + * | |
| 479 | + * @return string Host, or an empty string when the request carries none. | |
| 480 | + */ | |
| 481 | + private static function request_domain(): string { | |
| 482 | + if ( empty( $_SERVER['HTTP_HOST'] ) ) { | |
| 483 | + return ''; | |
| 484 | + } | |
| 485 | + | |
| 486 | + $host = strtolower( sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) ); | |
| 487 | + | |
| 488 | + if ( ':80' === substr( $host, -3 ) ) { | |
| 489 | + return substr( $host, 0, -3 ); | |
| 490 | + } | |
| 491 | + | |
| 492 | + if ( ':443' === substr( $host, -4 ) ) { | |
| 493 | + return substr( $host, 0, -4 ); | |
| 494 | + } | |
| 495 | + | |
| 496 | + return $host; | |
| 497 | + } | |
| 498 | + | |
| 499 | + /** | |
| 500 | + * Build a discovery document from the site that owns the resource. | |
| 501 | + * | |
| 502 | + * The switch is what makes the returned endpoints carry the subsite prefix, | |
| 503 | + * since every URL in the document comes from `home_url()` / `rest_url()`. | |
| 504 | + * Settings memoizes per setting name with no notion of which site it read | |
| 505 | + * from; it clears itself on `switch_blog` (see Settings::init), which is | |
| 506 | + * what stops the MCP-enabled check below answering for the previous site. | |
| 507 | + * | |
| 508 | + * Returns null when the owning site has MCP turned off: a site that is not | |
| 509 | + * serving MCP must not advertise an authorization server for it. | |
| 510 | + * | |
| 511 | + * @since 2.9.0 | |
| 512 | + * | |
| 513 | + * @param int $blog_id Blog to build from, 0 for the current site. | |
| 514 | + * @param string $doc Document type from the rewrite. | |
| 515 | + * @return array<string,mixed>|null | |
| 516 | + */ | |
| 517 | + private static function discovery_document_for( int $blog_id, string $doc ): ?array { | |
| 518 | + $switched = false; | |
| 519 | + | |
| 520 | + if ( $blog_id > 0 ) { | |
| 521 | + switch_to_blog( $blog_id ); | |
| 522 | + $switched = true; | |
| 523 | + } | |
| 524 | + | |
| 525 | + $data = null; | |
| 526 | + | |
| 527 | + try { | |
| 528 | + if ( self::is_enabled() ) { | |
| 529 | + $data = 'authorization-server' === $doc | |
| 530 | + ? Mcp_OAuth::authorization_server_metadata() | |
| 531 | + : Mcp_OAuth::protected_resource_metadata(); | |
| 532 | + } | |
| 533 | + } finally { | |
| 534 | + // A throw between the switch and the restore would leave the rest | |
| 535 | + // of the request, including shutdown hooks, running against the | |
| 536 | + // wrong site. Cheap to make impossible. | |
| 537 | + if ( $switched ) { | |
| 538 | + restore_current_blog(); | |
| 539 | + } | |
| 540 | + } | |
| 541 | + | |
| 542 | + return $data; | |
| 543 | + } | |
| 544 | + | |
| 545 | + /** | |
| 184 | 546 | * Serve the MCP endpoint on the pretty path. Runs on parse_request so it |
| 185 | 547 | * fires before the main query, and short-circuits WP entirely. |
| 186 | 548 | * |
| 187 | 549 | * @param \WP $wp The WP request object. |
| @@ -189,16 +551,30 @@ | ||
| 189 | 551 | */ |
| 190 | 552 | public function maybe_handle_pretty_endpoint( $wp ): void { |
| 191 | 553 | // OAuth discovery documents (served at the site root). |
| 192 | 554 | if ( ! empty( $wp->query_vars[ self::WELLKNOWN_QUERY_VAR ] ) ) { |
| 193 | - if ( ! self::is_enabled() ) { | |
| 555 | + // The path after the document type names the RESOURCE being | |
| 556 | + // discovered, and it used to be discarded (#516). Resolve it to | |
| 557 | + // the site that actually owns it, which on subdirectory multisite | |
| 558 | + // is how /ca/thinkrank/mcp stops being answered by the main site | |
| 559 | + // with endpoints that have no /ca/ in them. | |
| 560 | + $target = self::resolve_wellknown_target( $wp ); | |
| 561 | + | |
| 562 | + if ( null === $target ) { | |
| 194 | 563 | status_header( 404 ); |
| 195 | 564 | exit; |
| 196 | 565 | } |
| 197 | - $doc = (string) $wp->query_vars[ self::WELLKNOWN_QUERY_VAR ]; | |
| 198 | - $data = 'authorization-server' === $doc | |
| 199 | - ? Mcp_OAuth::authorization_server_metadata() | |
| 200 | - : Mcp_OAuth::protected_resource_metadata(); | |
| 566 | + | |
| 567 | + $data = self::discovery_document_for( | |
| 568 | + $target, | |
| 569 | + (string) $wp->query_vars[ self::WELLKNOWN_QUERY_VAR ] | |
| 570 | + ); | |
| 571 | + | |
| 572 | + if ( null === $data ) { | |
| 573 | + status_header( 404 ); | |
| 574 | + exit; | |
| 575 | + } | |
| 576 | + | |
| 201 | 577 | status_header( 200 ); |
| 202 | 578 | header( 'Content-Type: application/json; charset=utf-8' ); |
| 203 | 579 | // Discovery metadata is public + cacheable. |
| 204 | 580 | header( 'Cache-Control: public, max-age=3600' ); |
| @@ -219,8 +595,15 @@ | ||
| 219 | 595 | if ( empty( $wp->query_vars[ self::QUERY_VAR ] ) ) { |
| 220 | 596 | return; |
| 221 | 597 | } |
| 222 | 598 | |
| 599 | + // Mark the request so work that only front-end traffic must not pay for | |
| 600 | + // can tell this apart from an anonymous page view. An MCP call is a | |
| 601 | + // deliberate admin-equivalent action, but it arrives on the pretty | |
| 602 | + // /thinkrank/mcp route, so it is neither is_admin() nor REST_REQUEST | |
| 603 | + // and was invisible to those checks (#764). | |
| 604 | + self::$serving_request = true; | |
| 605 | + | |
| 223 | 606 | $request = new \WP_REST_Request( 'POST', '/' . self::NS . '/mcp' ); |
| 224 | 607 | $request->set_header( 'content-type', 'application/json' ); |
| 225 | 608 | // Carry the auth header + raw body from the live PHP request. |
| 226 | 609 | $auth = self::server_header( 'authorization' ); |
| @@ -329,14 +712,67 @@ | ||
| 329 | 712 | 'permission_callback' => [ $this, 'admin_permission' ], |
| 330 | 713 | ] |
| 331 | 714 | ); |
| 332 | 715 | |
| 716 | + // Connected AI apps (see #244): list the OAuth-connected clients plus a | |
| 717 | + // single combined row for the shared static token, and revoke either. | |
| 718 | + register_rest_route( | |
| 719 | + self::NS, | |
| 720 | + '/mcp/apps', | |
| 721 | + [ | |
| 722 | + 'methods' => 'GET', | |
| 723 | + 'callback' => [ $this, 'rest_apps' ], | |
| 724 | + 'permission_callback' => [ $this, 'admin_permission' ], | |
| 725 | + ] | |
| 726 | + ); | |
| 727 | + register_rest_route( | |
| 728 | + self::NS, | |
| 729 | + '/mcp/apps/revoke', | |
| 730 | + [ | |
| 731 | + 'methods' => 'POST', | |
| 732 | + 'callback' => [ $this, 'rest_revoke_app' ], | |
| 733 | + 'permission_callback' => [ $this, 'admin_permission' ], | |
| 734 | + 'args' => [ | |
| 735 | + 'client_id' => [ | |
| 736 | + 'type' => 'string', | |
| 737 | + 'required' => true, | |
| 738 | + 'description' => 'The OAuth client_id to revoke.', | |
| 739 | + ], | |
| 740 | + ], | |
| 741 | + ] | |
| 742 | + ); | |
| 743 | + | |
| 333 | 744 | // --- OAuth 2.1 authorization server (the "paste a URL only" path) - |
| 334 | 745 | // Discovery, dynamic client registration, and the token endpoint are |
| 335 | 746 | // all public (permission enforced inside): a client must reach them |
| 336 | 747 | // BEFORE it holds any credential. |
| 748 | + // | |
| 749 | + // The discovery documents are ALSO served here, not only at the | |
| 750 | + // /.well-known/ rewrites: hosts that resolve root /.well-known/ at | |
| 751 | + // their proxy edge (SiteGround) never let those requests reach | |
| 752 | + // WordPress, while /wp-json/ always arrives. The 401 challenge | |
| 753 | + // advertises this route (Mcp_OAuth::resource_metadata_url), so the | |
| 754 | + // flow survives on such hosts. | |
| 337 | 755 | register_rest_route( |
| 338 | 756 | self::NS, |
| 757 | + '/mcp/oauth/protected-resource', | |
| 758 | + [ | |
| 759 | + 'methods' => 'GET', | |
| 760 | + 'callback' => [ $this, 'rest_oauth_discovery_resource' ], | |
| 761 | + 'permission_callback' => '__return_true', | |
| 762 | + ] | |
| 763 | + ); | |
| 764 | + register_rest_route( | |
| 765 | + self::NS, | |
| 766 | + '/mcp/oauth/authorization-server', | |
| 767 | + [ | |
| 768 | + 'methods' => 'GET', | |
| 769 | + 'callback' => [ $this, 'rest_oauth_discovery_server' ], | |
| 770 | + 'permission_callback' => '__return_true', | |
| 771 | + ] | |
| 772 | + ); | |
| 773 | + register_rest_route( | |
| 774 | + self::NS, | |
| 339 | 775 | '/mcp/oauth/register', |
| 340 | 776 | [ |
| 341 | 777 | 'methods' => 'POST', |
| 342 | 778 | 'callback' => [ $this, 'rest_oauth_register' ], |
| @@ -387,12 +823,28 @@ | ||
| 387 | 823 | * |
| 388 | 824 | * @return \WP_REST_Response |
| 389 | 825 | */ |
| 390 | 826 | public function rest_connection(): \WP_REST_Response { |
| 827 | + $this->ensure_connected(); | |
| 391 | 828 | return rest_ensure_response( Mcp_Pairing::public_status() ); |
| 392 | 829 | } |
| 393 | 830 | |
| 394 | 831 | /** |
| 832 | + * Self-heal: whenever the admin views the MCP page with MCP enabled, make | |
| 833 | + * sure a connection token exists. New sites mint on the enable toggle (see | |
| 834 | + * #244), but a site that had MCP on before that behavior shipped would have | |
| 835 | + * no token; minting here — idempotent, admin-gated — keeps the connect | |
| 836 | + * recipes populated without a separate "Generate token" click. | |
| 837 | + * | |
| 838 | + * @return void | |
| 839 | + */ | |
| 840 | + private function ensure_connected(): void { | |
| 841 | + if ( self::is_enabled() && ! Mcp_Pairing::is_connected() ) { | |
| 842 | + Mcp_Pairing::connect(); | |
| 843 | + } | |
| 844 | + } | |
| 845 | + | |
| 846 | + /** | |
| 395 | 847 | * POST /mcp/connect — mint a connection token. |
| 396 | 848 | * |
| 397 | 849 | * @param \WP_REST_Request $request Carries optional read_only. |
| 398 | 850 | * @return \WP_REST_Response |
| @@ -433,11 +885,105 @@ | ||
| 433 | 885 | public function rest_self_test(): \WP_REST_Response { |
| 434 | 886 | return rest_ensure_response( Mcp_Self_Test::run() ); |
| 435 | 887 | } |
| 436 | 888 | |
| 889 | + /** | |
| 890 | + * GET /mcp/apps — the "Connected AI apps" list (see #244). | |
| 891 | + * | |
| 892 | + * @return \WP_REST_Response | |
| 893 | + */ | |
| 894 | + public function rest_apps(): \WP_REST_Response { | |
| 895 | + $this->ensure_connected(); | |
| 896 | + return rest_ensure_response( $this->apps_payload() ); | |
| 897 | + } | |
| 898 | + | |
| 899 | + /** | |
| 900 | + * POST /mcp/apps/revoke — cut off a single OAuth-connected app. Returns the | |
| 901 | + * refreshed app list so the UI updates in one round trip. (The shared static | |
| 902 | + * token has no per-client identity, so it is not listed or revoked here — it | |
| 903 | + * is rotated from the connect card via /mcp/rotate.) | |
| 904 | + * | |
| 905 | + * @param \WP_REST_Request $request Carries target + client_id. | |
| 906 | + * @return \WP_REST_Response|\WP_Error | |
| 907 | + */ | |
| 908 | + public function rest_revoke_app( \WP_REST_Request $request ) { | |
| 909 | + $client_id = (string) $request->get_param( 'client_id' ); | |
| 910 | + if ( '' === $client_id ) { | |
| 911 | + return new \WP_Error( | |
| 912 | + 'thinkrank_missing_client_id', | |
| 913 | + __( 'A client_id is required to revoke an OAuth app.', 'thinkrank' ), | |
| 914 | + [ 'status' => 400 ] | |
| 915 | + ); | |
| 916 | + } | |
| 917 | + Mcp_OAuth::revoke_client( $client_id ); | |
| 918 | + | |
| 919 | + return rest_ensure_response( $this->apps_payload() ); | |
| 920 | + } | |
| 921 | + | |
| 922 | + /** | |
| 923 | + * Build the "Connected AI apps" payload: the OAuth-connected clients, with | |
| 924 | + * the approving admin's display name resolved. Header-based (static-token) | |
| 925 | + * clients share one anonymous secret and so are not represented here. | |
| 926 | + * | |
| 927 | + * @return array<string,mixed> | |
| 928 | + */ | |
| 929 | + private function apps_payload(): array { | |
| 930 | + $oauth_apps = []; | |
| 931 | + foreach ( Mcp_OAuth::connected_apps() as $app ) { | |
| 932 | + $user = $app['user_id'] > 0 ? get_userdata( $app['user_id'] ) : false; | |
| 933 | + $oauth_apps[] = [ | |
| 934 | + 'client_id' => $app['client_id'], | |
| 935 | + 'name' => $app['name'], | |
| 936 | + 'read_only' => $app['read_only'], | |
| 937 | + 'approved_by' => $user ? $user->display_name : __( 'Unknown user', 'thinkrank' ), | |
| 938 | + 'connected_at' => $app['connected_at'], | |
| 939 | + 'last_used' => $app['last_used'], | |
| 940 | + ]; | |
| 941 | + } | |
| 942 | + | |
| 943 | + return [ | |
| 944 | + 'oauth_apps' => $oauth_apps, | |
| 945 | + ]; | |
| 946 | + } | |
| 947 | + | |
| 437 | 948 | // -- OAuth 2.1 handlers ------------------------------------------------ |
| 438 | 949 | |
| 439 | 950 | /** |
| 951 | + * GET /mcp/oauth/protected-resource — RFC 9728 metadata via REST. | |
| 952 | + * | |
| 953 | + * @return \WP_REST_Response|\WP_Error | |
| 954 | + */ | |
| 955 | + public function rest_oauth_discovery_resource() { | |
| 956 | + return $this->oauth_discovery_response( Mcp_OAuth::protected_resource_metadata() ); | |
| 957 | + } | |
| 958 | + | |
| 959 | + /** | |
| 960 | + * GET /mcp/oauth/authorization-server — RFC 8414 metadata via REST. | |
| 961 | + * | |
| 962 | + * @return \WP_REST_Response|\WP_Error | |
| 963 | + */ | |
| 964 | + public function rest_oauth_discovery_server() { | |
| 965 | + return $this->oauth_discovery_response( Mcp_OAuth::authorization_server_metadata() ); | |
| 966 | + } | |
| 967 | + | |
| 968 | + /** | |
| 969 | + * Shape one discovery document response: public, cacheable, and 404 when | |
| 970 | + * MCP is off — matching the /.well-known/ rewrites exactly, so a client | |
| 971 | + * sees the same truth regardless of which serving path reached it. | |
| 972 | + * | |
| 973 | + * @param array<string,mixed> $document Discovery metadata. | |
| 974 | + * @return \WP_REST_Response|\WP_Error | |
| 975 | + */ | |
| 976 | + private function oauth_discovery_response( array $document ) { | |
| 977 | + if ( ! self::is_enabled() ) { | |
| 978 | + return new \WP_Error( 'thinkrank_mcp_disabled', __( 'MCP is disabled on this site.', 'thinkrank' ), [ 'status' => 404 ] ); | |
| 979 | + } | |
| 980 | + $response = new \WP_REST_Response( $document, 200 ); | |
| 981 | + $response->header( 'Cache-Control', 'public, max-age=3600' ); | |
| 982 | + return $response; | |
| 983 | + } | |
| 984 | + | |
| 985 | + /** | |
| 440 | 986 | * POST /mcp/oauth/register — RFC 7591 dynamic client registration. |
| 441 | 987 | * |
| 442 | 988 | * @param \WP_REST_Request $request JSON body with redirect_uris. |
| 443 | 989 | * @return \WP_REST_Response|\WP_Error |
| @@ -520,17 +1066,18 @@ | ||
| 520 | 1066 | * |
| 521 | 1067 | * @return void |
| 522 | 1068 | */ |
| 523 | 1069 | public function handle_authorize_page(): void { |
| 1070 | + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- compared against a literal after strtoupper(); nothing is stored or echoed. | |
| 524 | 1071 | $is_post = isset( $_SERVER['REQUEST_METHOD'] ) && 'POST' === strtoupper( (string) wp_unslash( $_SERVER['REQUEST_METHOD'] ) ); |
| 525 | 1072 | // Params come from GET on the consent link and POST on the form submit. |
| 526 | 1073 | // Nonce is verified below before any POST value is acted on. |
| 527 | - // phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing | |
| 1074 | + // phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- read verbatim by oauth_param(); see its docblock for why, and where each value is validated or escaped instead. | |
| 528 | 1075 | $source = $is_post ? $_POST : $_GET; |
| 529 | - // phpcs:enable | |
| 1076 | + // phpcs:enable WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized | |
| 530 | 1077 | $params = []; |
| 531 | 1078 | foreach ( [ 'client_id', 'redirect_uri', 'response_type', 'code_challenge', 'code_challenge_method', 'scope', 'state', 'approve', 'deny', '_thinkrank_oauth_nonce' ] as $k ) { |
| 532 | - $params[ $k ] = isset( $source[ $k ] ) ? sanitize_text_field( wp_unslash( $source[ $k ] ) ) : ''; | |
| 1079 | + $params[ $k ] = self::oauth_param( $source, $k ); | |
| 533 | 1080 | } |
| 534 | 1081 | |
| 535 | 1082 | // Validate the OAuth params before touching the session. |
| 536 | 1083 | $req = Mcp_OAuth::validate_authorize_request( $params ); |
| @@ -645,10 +1192,10 @@ | ||
| 645 | 1192 | private function emit_consent_screen( array $req ): void { |
| 646 | 1193 | $read_only = Mcp_OAuth::scope_is_read_only( $req['scope'] ); |
| 647 | 1194 | $access_label = $read_only ? __( 'Read-only', 'thinkrank' ) : __( 'Read & write', 'thinkrank' ); |
| 648 | 1195 | $access_desc = $read_only |
| 649 | - ? __( 'Review your SEO across posts and site settings — metadata, schema, sitemaps, robots, social, and SEO scores. No changes are made.', 'thinkrank' ) | |
| 650 | - : __( 'Read and improve your SEO across posts and site settings — metadata, schema, sitemaps, robots, social, indexing, and SEO scores.', 'thinkrank' ); | |
| 1196 | + ? __( 'Review your SEO across posts and site settings metadata, schema, sitemaps, robots, social, and SEO scores. No changes are made.', 'thinkrank' ) | |
| 1197 | + : __( 'Read and improve your SEO across posts and site settings metadata, schema, sitemaps, robots, social, indexing, and SEO scores.', 'thinkrank' ); | |
| 651 | 1198 | $client = '' !== $req['client_name'] ? $req['client_name'] : __( 'An AI assistant', 'thinkrank' ); |
| 652 | 1199 | $action_url = Mcp_OAuth::authorize_url(); |
| 653 | 1200 | $nonce = wp_create_nonce( 'thinkrank_oauth_consent' ); |
| 654 | 1201 | $user = wp_get_current_user(); |
| @@ -762,17 +1309,93 @@ | ||
| 762 | 1309 | |
| 763 | 1310 | // -- Helpers -- |
| 764 | 1311 | |
| 765 | 1312 | /** |
| 1313 | + * Read one /authorize parameter verbatim. | |
| 1314 | + * | |
| 1315 | + * Deliberately NOT sanitize_text_field(). That function exists to make | |
| 1316 | + * untrusted text safe to store and display, and part of what it does is | |
| 1317 | + * strip %XX sequences as an anti-obfuscation measure. Applied to an OAuth | |
| 1318 | + * protocol value it quietly changes the value's meaning. | |
| 1319 | + * | |
| 1320 | + * The concrete failure: registration stores redirect_uris raw from a JSON | |
| 1321 | + * body, but at /authorize the same URI arrives as a query parameter, so | |
| 1322 | + * PHP has already URL-decoded it — and sanitising then removed the percent | |
| 1323 | + * sequences. A client registered with `.../cb?next=%2Fdashboard` was | |
| 1324 | + * compared as `.../cb?next=dashboard`, failed the strict match, and was | |
| 1325 | + * told `invalid_redirect_uri` for sending exactly what it registered | |
| 1326 | + * (#487). `state` has the same problem: it is opaque to us and must | |
| 1327 | + * round-trip byte for byte, or the client aborts its own callback. | |
| 1328 | + * | |
| 1329 | + * Protocol identifiers want validation and rejection, not cleaning. Every | |
| 1330 | + * value read here is constrained somewhere better suited to it: | |
| 1331 | + * - redirect_uri strict in_array() against the client's registered set | |
| 1332 | + * - client_id must resolve to a registered client | |
| 1333 | + * - response_type must equal 'code' | |
| 1334 | + * - code_challenge_method must equal 'S256' | |
| 1335 | + * - code_challenge validated against the RFC 7636 character set | |
| 1336 | + * - scope intersected with SUPPORTED_SCOPES | |
| 1337 | + * - state opaque; escaped at output (esc_attr / rawurlencode) | |
| 1338 | + * - approve/deny tested for emptiness only | |
| 1339 | + * - the nonce passed to wp_verify_nonce() | |
| 1340 | + * | |
| 1341 | + * An array value (`?state[]=x`) reads as absent rather than becoming the | |
| 1342 | + * string "Array". | |
| 1343 | + * | |
| 1344 | + * @since 2.1.0 | |
| 1345 | + * | |
| 1346 | + * @param array<string,mixed> $source $_GET or $_POST. | |
| 1347 | + * @param string $key Parameter name. | |
| 1348 | + * @return string | |
| 1349 | + */ | |
| 1350 | + private static function oauth_param( array $source, string $key ): string { | |
| 1351 | + if ( ! isset( $source[ $key ] ) || ! is_scalar( $source[ $key ] ) ) { | |
| 1352 | + return ''; | |
| 1353 | + } | |
| 1354 | + | |
| 1355 | + return (string) wp_unslash( $source[ $key ] ); | |
| 1356 | + } | |
| 1357 | + | |
| 1358 | + /** | |
| 766 | 1359 | * Read an inbound HTTP header from $_SERVER (for the pretty path). |
| 767 | 1360 | * |
| 1361 | + * Mirrors WP_REST_Server::get_headers(): on Apache with CGI/FastCGI/suPHP the | |
| 1362 | + * Authorization header never lands in HTTP_AUTHORIZATION. WordPress's own | |
| 1363 | + * .htaccess passthrough re-publishes it as REDIRECT_HTTP_AUTHORIZATION, and a | |
| 1364 | + * few Apache module setups populate neither key but do answer getallheaders(). | |
| 1365 | + * The REST route gets this handling from core; the pretty route builds its own | |
| 1366 | + * WP_REST_Request, so it has to do the same here or it 401s on those hosts. | |
| 1367 | + * | |
| 768 | 1368 | * @param string $name Header name. |
| 769 | 1369 | * @return string|null |
| 770 | 1370 | */ |
| 771 | 1371 | private static function server_header( string $name ): ?string { |
| 772 | 1372 | $key = 'HTTP_' . strtoupper( str_replace( '-', '_', $name ) ); |
| 1373 | + | |
| 773 | 1374 | // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- token compared constant-time downstream; raw header needed verbatim. |
| 774 | - return isset( $_SERVER[ $key ] ) ? wp_unslash( $_SERVER[ $key ] ) : null; | |
| 1375 | + if ( isset( $_SERVER[ $key ] ) && '' !== $_SERVER[ $key ] ) { | |
| 1376 | + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- as above. | |
| 1377 | + return wp_unslash( $_SERVER[ $key ] ); | |
| 1378 | + } | |
| 1379 | + | |
| 1380 | + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- as above. | |
| 1381 | + if ( isset( $_SERVER[ 'REDIRECT_' . $key ] ) && '' !== $_SERVER[ 'REDIRECT_' . $key ] ) { | |
| 1382 | + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- as above. | |
| 1383 | + return wp_unslash( $_SERVER[ 'REDIRECT_' . $key ] ); | |
| 1384 | + } | |
| 1385 | + | |
| 1386 | + if ( function_exists( 'getallheaders' ) ) { | |
| 1387 | + $headers = getallheaders(); | |
| 1388 | + if ( is_array( $headers ) ) { | |
| 1389 | + foreach ( $headers as $header => $value ) { | |
| 1390 | + if ( 0 === strcasecmp( (string) $header, $name ) && '' !== (string) $value ) { | |
| 1391 | + return (string) $value; | |
| 1392 | + } | |
| 1393 | + } | |
| 1394 | + } | |
| 1395 | + } | |
| 1396 | + | |
| 1397 | + return null; | |
| 775 | 1398 | } |
| 776 | 1399 | |
| 777 | 1400 | /** |
| 778 | 1401 | * Emit a WP_REST_Response as a JSON HTTP response and stop. |
| @@ -785,12 +1408,19 @@ | ||
| 785 | 1408 | // MCP Streamable HTTP: advertise the protocol version we speak so a |
| 786 | 1409 | // strict client can pin it. We answer JSON (a spec-permitted response |
| 787 | 1410 | // type); we never open an SSE stream, so no session header is needed. |
| 788 | 1411 | header( 'MCP-Protocol-Version: ' . Mcp_Server::PROTOCOL_VERSION ); |
| 1412 | + // Never cached. The pretty endpoint can carry the pairing token in its | |
| 1413 | + // path, so a shared cache or proxy holding a response keyed on that URL | |
| 1414 | + // would keep an admin-equivalent credential in its store (#396). | |
| 1415 | + header( 'Cache-Control: no-store, private' ); | |
| 789 | 1416 | // Forward any headers the handler set (notably WWW-Authenticate on a |
| 790 | 1417 | // 401, which drives the OAuth discovery flow). |
| 791 | 1418 | foreach ( $response->get_headers() as $name => $value ) { |
| 792 | - header( $name . ': ' . $value ); | |
| 1419 | + // Re-assert the status on every header: PHP special-cases | |
| 1420 | + // WWW-Authenticate and forces a 401 when no status is given, | |
| 1421 | + // which would silently mask the 429 lockout response. | |
| 1422 | + header( $name . ': ' . $value, true, $response->get_status() ); | |
| 793 | 1423 | } |
| 794 | 1424 | $data = $response->get_data(); |
| 795 | 1425 | if ( null !== $data ) { |
| 796 | 1426 | header( 'Content-Type: application/json; charset=utf-8' ); |