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 +398 -74 2.3.0 → 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()
@@ -146,87 +178,197 @@
146 178 *
147 179 * @return void
148 180 */
149 181 public function add_rewrite(): void {
150 - // Token-in-URL form: /thinkrank/mcp/<token> — a single string the user
151 - // pastes into their AI client (no separate token field). The bare
152 - // /thinkrank/mcp still works with a Bearer token.
153 - add_rewrite_rule(
154 - '^thinkrank/mcp/([a-f0-9]{64})/?$',
155 - 'index.php?' . self::QUERY_VAR . '=1&' . self::TOKEN_QUERY_VAR . '=$matches[1]',
156 - 'top'
157 - );
158 - add_rewrite_rule( '^thinkrank/mcp/?$', 'index.php?' . self::QUERY_VAR . '=1', 'top' );
182 + $rules = self::rewrite_rules();
159 183
160 - // OAuth discovery documents. RFC 9728 §3.1 / RFC 8414 §3.1 place the
161 - // `.well-known` segment BEFORE the resource path, so our resource at
162 - // /thinkrank/mcp is discovered at the path-suffixed form:
163 - // /.well-known/oauth-protected-resource/thinkrank/mcp
164 - // /.well-known/oauth-authorization-server/thinkrank/mcp
165 - // The OAuth issuer is the path-based identifier home_url('/thinkrank/mcp')
166 - // (see Mcp_OAuth::issuer), so spec-compliant clients derive exactly
167 - // these URLs — and the rule stays specific to OUR path. That matters
168 - // for coexistence: another plugin serving its own MCP OAuth surface
169 - // (e.g. xSpeed) claims the generic `(?:/.*)?` root rule, and rewrite
170 - // rules are keyed by regex, so a shared broad rule would be silently
171 - // overwritten by whichever plugin registers last.
172 - add_rewrite_rule(
173 - '^\.well-known/oauth-(protected-resource|authorization-server)/thinkrank/mcp/?$',
174 - 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]',
175 - 'top'
176 - );
177 - // Root-form fallback for clients that only try the bare well-known
178 - // URL. Harmless when another plugin also registers this exact regex —
179 - // last registrant wins, and our clients use the path-suffixed form.
180 - add_rewrite_rule(
181 - '^\.well-known/oauth-(protected-resource|authorization-server)(?:/.*)?/?$',
182 - 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]',
183 - 'top'
184 - );
185 - // Suffix form: <issuer>/.well-known/... . RFC 8414 specifies the
186 - // path-INSERT form above, but the older OpenID Connect Discovery
187 - // convention appends instead, and clients built on an OIDC library
188 - // try that shape first (sometimes only that shape). Serving both
189 - // costs two rules and removes a whole class of "server does not
190 - // implement OAuth" failures from clients that never fall back.
191 - add_rewrite_rule(
192 - '^thinkrank/mcp/\.well-known/oauth-(protected-resource|authorization-server)/?$',
193 - 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]',
194 - 'top'
195 - );
196 - add_rewrite_rule(
197 - '^thinkrank/mcp/\.well-known/openid-configuration/?$',
198 - 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=authorization-server',
199 - 'top'
200 - );
184 + foreach ( $rules as $regex => $query ) {
185 + add_rewrite_rule( $regex, $query, 'top' );
186 + }
201 187
202 - // Browser-facing OAuth consent page — served OUTSIDE REST so cookie
203 - // auth (is_user_logged_in) works after the wp-login round-trip.
204 - add_rewrite_rule( '^thinkrank/authorize/?$', 'index.php?' . self::AUTHORIZE_QUERY_VAR . '=1', 'top' );
205 -
206 188 // Self-heal: flush once if ANY of our rules is missing from the stored
207 189 // rewrite table, so the endpoints work without a manual permalink
208 190 // re-save (and newly added rules trigger a re-flush on upgrade).
209 - $expected = [
210 - '^thinkrank/mcp/([a-f0-9]{64})/?$',
211 - '^thinkrank/mcp/?$',
212 - '^\.well-known/oauth-(protected-resource|authorization-server)/thinkrank/mcp/?$',
213 - '^thinkrank/mcp/\.well-known/oauth-(protected-resource|authorization-server)/?$',
214 - '^thinkrank/mcp/\.well-known/openid-configuration/?$',
215 - '^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',
216 302 ];
217 - $rules = get_option( 'rewrite_rules' );
218 - if ( is_array( $rules ) ) {
219 - foreach ( $expected as $rule ) {
220 - if ( ! isset( $rules[ $rule ] ) ) {
221 - flush_rewrite_rules( false );
222 - break;
223 - }
224 - }
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]';
225 307 }
308 +
309 + return $rules;
226 310 }
227 311
228 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 + /**
229 371 * Register our query vars.
230 372 *
231 373 * @param string[] $vars Registered query vars.
232 374 * @return string[]
@@ -234,13 +376,174 @@
234 376 public function register_query_var( array $vars ): array {
235 377 $vars[] = self::QUERY_VAR;
236 378 $vars[] = self::TOKEN_QUERY_VAR;
237 379 $vars[] = self::WELLKNOWN_QUERY_VAR;
380 + $vars[] = self::WELLKNOWN_RESOURCE_QUERY_VAR;
238 381 $vars[] = self::AUTHORIZE_QUERY_VAR;
239 382 return $vars;
240 383 }
241 384
242 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 + /**
243 546 * Serve the MCP endpoint on the pretty path. Runs on parse_request so it
244 547 * fires before the main query, and short-circuits WP entirely.
245 548 *
246 549 * @param \WP $wp The WP request object.
@@ -248,16 +551,30 @@
248 551 */
249 552 public function maybe_handle_pretty_endpoint( $wp ): void {
250 553 // OAuth discovery documents (served at the site root).
251 554 if ( ! empty( $wp->query_vars[ self::WELLKNOWN_QUERY_VAR ] ) ) {
252 - 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 ) {
253 563 status_header( 404 );
254 564 exit;
255 565 }
256 - $doc = (string) $wp->query_vars[ self::WELLKNOWN_QUERY_VAR ];
257 - $data = 'authorization-server' === $doc
258 - ? Mcp_OAuth::authorization_server_metadata()
259 - : 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 +
260 577 status_header( 200 );
261 578 header( 'Content-Type: application/json; charset=utf-8' );
262 579 // Discovery metadata is public + cacheable.
263 580 header( 'Cache-Control: public, max-age=3600' );
@@ -277,8 +594,15 @@
277 594
278 595 if ( empty( $wp->query_vars[ self::QUERY_VAR ] ) ) {
279 596 return;
280 597 }
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;
281 605
282 606 $request = new \WP_REST_Request( 'POST', '/' . self::NS . '/mcp' );
283 607 $request->set_header( 'content-type', 'application/json' );
284 608 // Carry the auth header + raw body from the live PHP request.