PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.10.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.10.0
2.10.0 2.9.0 2.8.0 2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 All 51 releases
← All changes | includes/mcp/class-mcp-manager.php +483 -77 2.0.2 → 2.10.0 View file →
@@ -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,8 +113,14 @@
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' ] );
@@ -140,87 +178,197 @@
140 178 *
141 179 * @return void
142 180 */
143 181 public function add_rewrite(): void {
144 - // Token-in-URL form: /thinkrank/mcp/<token> — a single string the user
145 - // pastes into their AI client (no separate token field). The bare
146 - // /thinkrank/mcp still works with a Bearer token.
147 - add_rewrite_rule(
148 - '^thinkrank/mcp/([a-f0-9]{64})/?$',
149 - 'index.php?' . self::QUERY_VAR . '=1&' . self::TOKEN_QUERY_VAR . '=$matches[1]',
150 - 'top'
151 - );
152 - add_rewrite_rule( '^thinkrank/mcp/?$', 'index.php?' . self::QUERY_VAR . '=1', 'top' );
182 + $rules = self::rewrite_rules();
153 183
154 - // OAuth discovery documents. RFC 9728 §3.1 / RFC 8414 §3.1 place the
155 - // `.well-known` segment BEFORE the resource path, so our resource at
156 - // /thinkrank/mcp is discovered at the path-suffixed form:
157 - // /.well-known/oauth-protected-resource/thinkrank/mcp
158 - // /.well-known/oauth-authorization-server/thinkrank/mcp
159 - // The OAuth issuer is the path-based identifier home_url('/thinkrank/mcp')
160 - // (see Mcp_OAuth::issuer), so spec-compliant clients derive exactly
161 - // these URLs — and the rule stays specific to OUR path. That matters
162 - // for coexistence: another plugin serving its own MCP OAuth surface
163 - // (e.g. xSpeed) claims the generic `(?:/.*)?` root rule, and rewrite
164 - // rules are keyed by regex, so a shared broad rule would be silently
165 - // overwritten by whichever plugin registers last.
166 - add_rewrite_rule(
167 - '^\.well-known/oauth-(protected-resource|authorization-server)/thinkrank/mcp/?$',
168 - 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]',
169 - 'top'
170 - );
171 - // Root-form fallback for clients that only try the bare well-known
172 - // URL. Harmless when another plugin also registers this exact regex —
173 - // last registrant wins, and our clients use the path-suffixed form.
174 - add_rewrite_rule(
175 - '^\.well-known/oauth-(protected-resource|authorization-server)(?:/.*)?/?$',
176 - 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]',
177 - 'top'
178 - );
179 - // Suffix form: <issuer>/.well-known/... . RFC 8414 specifies the
180 - // path-INSERT form above, but the older OpenID Connect Discovery
181 - // convention appends instead, and clients built on an OIDC library
182 - // try that shape first (sometimes only that shape). Serving both
183 - // costs two rules and removes a whole class of "server does not
184 - // implement OAuth" failures from clients that never fall back.
185 - add_rewrite_rule(
186 - '^thinkrank/mcp/\.well-known/oauth-(protected-resource|authorization-server)/?$',
187 - 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]',
188 - 'top'
189 - );
190 - add_rewrite_rule(
191 - '^thinkrank/mcp/\.well-known/openid-configuration/?$',
192 - 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=authorization-server',
193 - 'top'
194 - );
184 + foreach ( $rules as $regex => $query ) {
185 + add_rewrite_rule( $regex, $query, 'top' );
186 + }
195 187
196 - // Browser-facing OAuth consent page — served OUTSIDE REST so cookie
197 - // auth (is_user_logged_in) works after the wp-login round-trip.
198 - add_rewrite_rule( '^thinkrank/authorize/?$', 'index.php?' . self::AUTHORIZE_QUERY_VAR . '=1', 'top' );
199 -
200 188 // Self-heal: flush once if ANY of our rules is missing from the stored
201 189 // rewrite table, so the endpoints work without a manual permalink
202 190 // re-save (and newly added rules trigger a re-flush on upgrade).
203 - $expected = [
204 - '^thinkrank/mcp/([a-f0-9]{64})/?$',
205 - '^thinkrank/mcp/?$',
206 - '^\.well-known/oauth-(protected-resource|authorization-server)/thinkrank/mcp/?$',
207 - '^thinkrank/mcp/\.well-known/oauth-(protected-resource|authorization-server)/?$',
208 - '^thinkrank/mcp/\.well-known/openid-configuration/?$',
209 - '^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',
210 302 ];
211 - $rules = get_option( 'rewrite_rules' );
212 - if ( is_array( $rules ) ) {
213 - foreach ( $expected as $rule ) {
214 - if ( ! isset( $rules[ $rule ] ) ) {
215 - flush_rewrite_rules( false );
216 - break;
217 - }
218 - }
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]';
219 307 }
308 +
309 + return $rules;
220 310 }
221 311
222 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 + /**
223 371 * Register our query vars.
224 372 *
225 373 * @param string[] $vars Registered query vars.
226 374 * @return string[]
@@ -228,13 +376,174 @@
228 376 public function register_query_var( array $vars ): array {
229 377 $vars[] = self::QUERY_VAR;
230 378 $vars[] = self::TOKEN_QUERY_VAR;
231 379 $vars[] = self::WELLKNOWN_QUERY_VAR;
380 + $vars[] = self::WELLKNOWN_RESOURCE_QUERY_VAR;
232 381 $vars[] = self::AUTHORIZE_QUERY_VAR;
233 382 return $vars;
234 383 }
235 384
236 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 + /**
237 546 * Serve the MCP endpoint on the pretty path. Runs on parse_request so it
238 547 * fires before the main query, and short-circuits WP entirely.
239 548 *
240 549 * @param \WP $wp The WP request object.
@@ -242,16 +551,30 @@
242 551 */
243 552 public function maybe_handle_pretty_endpoint( $wp ): void {
244 553 // OAuth discovery documents (served at the site root).
245 554 if ( ! empty( $wp->query_vars[ self::WELLKNOWN_QUERY_VAR ] ) ) {
246 - 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 ) {
247 563 status_header( 404 );
248 564 exit;
249 565 }
250 - $doc = (string) $wp->query_vars[ self::WELLKNOWN_QUERY_VAR ];
251 - $data = 'authorization-server' === $doc
252 - ? Mcp_OAuth::authorization_server_metadata()
253 - : 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 +
254 577 status_header( 200 );
255 578 header( 'Content-Type: application/json; charset=utf-8' );
256 579 // Discovery metadata is public + cacheable.
257 580 header( 'Cache-Control: public, max-age=3600' );
@@ -272,8 +595,15 @@
272 595 if ( empty( $wp->query_vars[ self::QUERY_VAR ] ) ) {
273 596 return;
274 597 }
275 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 +
276 606 $request = new \WP_REST_Request( 'POST', '/' . self::NS . '/mcp' );
277 607 $request->set_header( 'content-type', 'application/json' );
278 608 // Carry the auth header + raw body from the live PHP request.
279 609 $auth = self::server_header( 'authorization' );
@@ -740,14 +1070,14 @@
740 1070 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- compared against a literal after strtoupper(); nothing is stored or echoed.
741 1071 $is_post = isset( $_SERVER['REQUEST_METHOD'] ) && 'POST' === strtoupper( (string) wp_unslash( $_SERVER['REQUEST_METHOD'] ) );
742 1072 // Params come from GET on the consent link and POST on the form submit.
743 1073 // Nonce is verified below before any POST value is acted on.
744 - // phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each member is sanitize_text_field()ed in the loop below; nothing reads $source directly.
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.
745 1075 $source = $is_post ? $_POST : $_GET;
746 1076 // phpcs:enable WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
747 1077 $params = [];
748 1078 foreach ( [ 'client_id', 'redirect_uri', 'response_type', 'code_challenge', 'code_challenge_method', 'scope', 'state', 'approve', 'deny', '_thinkrank_oauth_nonce' ] as $k ) {
749 - $params[ $k ] = isset( $source[ $k ] ) ? sanitize_text_field( wp_unslash( $source[ $k ] ) ) : '';
1079 + $params[ $k ] = self::oauth_param( $source, $k );
750 1080 }
751 1081
752 1082 // Validate the OAuth params before touching the session.
753 1083 $req = Mcp_OAuth::validate_authorize_request( $params );
@@ -979,17 +1309,93 @@
979 1309
980 1310 // -- Helpers --
981 1311
982 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 + /**
983 1359 * Read an inbound HTTP header from $_SERVER (for the pretty path).
984 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 + *
985 1368 * @param string $name Header name.
986 1369 * @return string|null
987 1370 */
988 1371 private static function server_header( string $name ): ?string {
989 1372 $key = 'HTTP_' . strtoupper( str_replace( '-', '_', $name ) );
1373 +
990 1374 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- token compared constant-time downstream; raw header needed verbatim.
991 - 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;
992 1398 }
993 1399
994 1400 /**
995 1401 * Emit a WP_REST_Response as a JSON HTTP response and stop.