PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.9.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.9.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 1.0.2 All 50 releases
← All changes | includes/mcp/class-mcp-manager.php +202 -7 2.4.0 → 2.9.0 View file →
@@ -63,8 +63,14 @@
63 63 */
64 64 private const WELLKNOWN_QUERY_VAR = 'thinkrank_mcp_wellknown';
65 65
66 66 /**
67 + * Query var carrying the resource path a root-form discovery request asked
68 + * about, so the handler can tell whether that resource is ours (#516).
69 + */
70 + private const WELLKNOWN_RESOURCE_QUERY_VAR = 'thinkrank_mcp_wellknown_resource';
71 +
72 + /**
67 73 * Query var flagging the browser-facing OAuth authorize page. This is
68 74 * served OUTSIDE the REST API on purpose: a REST route only honors cookie
69 75 * auth when a REST nonce accompanies it, but a browser arriving from
70 76 * wp-login carries the cookie with NO nonce — so is_user_logged_in()
@@ -176,11 +182,20 @@
176 182 );
177 183 // Root-form fallback for clients that only try the bare well-known
178 184 // URL. Harmless when another plugin also registers this exact regex —
179 185 // last registrant wins, and our clients use the path-suffixed form.
186 + //
187 + // The trailing path is CAPTURED rather than discarded (#516). It names
188 + // the resource the client is asking about, and answering for a resource
189 + // that is not ours is how this rule broke subdirectory multisite: the
190 + // network root belongs to the main site, so a client discovering
191 + // /ca/thinkrank/mcp was served the MAIN site's document, with every
192 + // endpoint missing the /ca/ prefix. The same rule also answered for
193 + // another plugin's resource path on a plain single site. The handler
194 + // below compares the capture with our own path and declines the rest.
180 195 add_rewrite_rule(
181 - '^\.well-known/oauth-(protected-resource|authorization-server)(?:/.*)?/?$',
182 - 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]',
196 + '^\.well-known/oauth-(protected-resource|authorization-server)(/.*)?/?$',
197 + 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]&' . self::WELLKNOWN_RESOURCE_QUERY_VAR . '=$matches[2]',
183 198 'top'
184 199 );
185 200 // Suffix form: <issuer>/.well-known/... . RFC 8414 specifies the
186 201 // path-INSERT form above, but the older OpenID Connect Discovery
@@ -209,8 +224,13 @@
209 224 $expected = [
210 225 '^thinkrank/mcp/([a-f0-9]{64})/?$',
211 226 '^thinkrank/mcp/?$',
212 227 '^\.well-known/oauth-(protected-resource|authorization-server)/thinkrank/mcp/?$',
228 + // Listed so an upgrade re-flushes and the pre-#516 rule, which
229 + // discarded the resource path, leaves the stored rewrite table.
230 + // Without this the old regex keeps matching until someone re-saves
231 + // permalinks by hand.
232 + '^\.well-known/oauth-(protected-resource|authorization-server)(/.*)?/?$',
213 233 '^thinkrank/mcp/\.well-known/oauth-(protected-resource|authorization-server)/?$',
214 234 '^thinkrank/mcp/\.well-known/openid-configuration/?$',
215 235 '^thinkrank/authorize/?$',
216 236 ];
@@ -234,13 +254,174 @@
234 254 public function register_query_var( array $vars ): array {
235 255 $vars[] = self::QUERY_VAR;
236 256 $vars[] = self::TOKEN_QUERY_VAR;
237 257 $vars[] = self::WELLKNOWN_QUERY_VAR;
258 + $vars[] = self::WELLKNOWN_RESOURCE_QUERY_VAR;
238 259 $vars[] = self::AUTHORIZE_QUERY_VAR;
239 260 return $vars;
240 261 }
241 262
242 263 /**
264 + * Resolve a root-form discovery request to the site that owns the resource.
265 + *
266 + * The path-suffixed and issuer-suffixed rules are already pinned to
267 + * `thinkrank/mcp`, so only the broad root-form rule can arrive here naming
268 + * something else. Three outcomes:
269 + *
270 + * - `0` — serve from the current site. That covers the bare form
271 + * (`/.well-known/oauth-authorization-server`, the whole reason the
272 + * fallback rule exists) and this site's own endpoint path.
273 + * - a blog id — a subdirectory multisite request for another site's
274 + * resource. On subdirectory multisite everything under the network root
275 + * is served by the MAIN site, so a client discovering
276 + * `/ca/thinkrank/mcp` lands here; the document has to be built from the
277 + * `/ca/` site or every endpoint in it loses the prefix.
278 + * - `null` — not ours. Another plugin's resource, an unknown site path, or
279 + * a path that merely contains ours.
280 + *
281 + * @since 2.9.0
282 + *
283 + * @param \WP $wp The WP request object.
284 + * @return int|null Blog id to serve from, 0 for the current site, null to decline.
285 + */
286 + private static function resolve_wellknown_target( $wp ): ?int {
287 + $requested = isset( $wp->query_vars[ self::WELLKNOWN_RESOURCE_QUERY_VAR ] )
288 + ? trim( (string) $wp->query_vars[ self::WELLKNOWN_RESOURCE_QUERY_VAR ], '/' )
289 + : '';
290 +
291 + $ours = trim( Mcp_Pairing::SITE_ENDPOINT_PATH, '/' );
292 +
293 + if ( '' === $requested || $requested === $ours ) {
294 + return 0;
295 + }
296 +
297 + if ( ! is_multisite() || ! function_exists( 'get_site_by_path' ) ) {
298 + return null;
299 + }
300 +
301 + // Whatever precedes our endpoint path is the candidate site path:
302 + // `ca/thinkrank/mcp` -> `/ca/`. Matching the tail as a whole path
303 + // segment, not a substring, so `thinkrank/mcp-other` cannot qualify.
304 + $candidate = '/' . $requested;
305 + $suffix = '/' . $ours;
306 +
307 + if ( substr( $candidate, - strlen( $suffix ) ) !== $suffix ) {
308 + return null;
309 + }
310 +
311 + $site_path = substr( $candidate, 0, - strlen( $ours ) );
312 + $domain = self::request_domain();
313 +
314 + if ( '' === $domain || '' === $site_path ) {
315 + return null;
316 + }
317 +
318 + $site = get_site_by_path( $domain, $site_path );
319 +
320 + if ( ! $site ) {
321 + return null;
322 + }
323 +
324 + // get_site_by_path() walks the path segments and falls back to the
325 + // network's root site when none match, so an unknown prefix comes back
326 + // as the MAIN site rather than as nothing. Taking that at face value
327 + // reinstates the exact bug for every path that is not a real subsite:
328 + // /nope/thinkrank/mcp would be answered with the main site's document.
329 + // Require the match to be the path that was actually asked for.
330 + if ( untrailingslashit( (string) $site->path ) !== untrailingslashit( $site_path ) ) {
331 + return null;
332 + }
333 +
334 + // get_sites() applies no status filter, so a site the network has taken
335 + // out of service resolves like any other. Advertising an authorization
336 + // server for one would point a client at an endpoint that cannot serve
337 + // it. `public` is deliberately NOT checked: on multisite that flag is
338 + // search-engine visibility, not availability, and a site can reasonably
339 + // be hidden from search while still running MCP.
340 + if ( ! empty( $site->archived ) || ! empty( $site->deleted ) || ! empty( $site->spam ) ) {
341 + return null;
342 + }
343 +
344 + return (int) $site->blog_id === get_current_blog_id() ? 0 : (int) $site->blog_id;
345 + }
346 +
347 + /**
348 + * Host for a `get_site_by_path()` lookup.
349 + *
350 + * Mirrors what WordPress itself stores in `wp_blogs`: core strips only the
351 + * default ports when it resolves the current site, so a development network
352 + * running on a non-default port keeps it, and stripping every port here
353 + * would fail to match those rows.
354 + *
355 + * @since 2.9.0
356 + *
357 + * @return string Host, or an empty string when the request carries none.
358 + */
359 + private static function request_domain(): string {
360 + if ( empty( $_SERVER['HTTP_HOST'] ) ) {
361 + return '';
362 + }
363 +
364 + $host = strtolower( sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) );
365 +
366 + if ( ':80' === substr( $host, -3 ) ) {
367 + return substr( $host, 0, -3 );
368 + }
369 +
370 + if ( ':443' === substr( $host, -4 ) ) {
371 + return substr( $host, 0, -4 );
372 + }
373 +
374 + return $host;
375 + }
376 +
377 + /**
378 + * Build a discovery document from the site that owns the resource.
379 + *
380 + * The switch is what makes the returned endpoints carry the subsite prefix,
381 + * since every URL in the document comes from `home_url()` / `rest_url()`.
382 + * Settings memoizes per setting name with no notion of which site it read
383 + * from; it clears itself on `switch_blog` (see Settings::init), which is
384 + * what stops the MCP-enabled check below answering for the previous site.
385 + *
386 + * Returns null when the owning site has MCP turned off: a site that is not
387 + * serving MCP must not advertise an authorization server for it.
388 + *
389 + * @since 2.9.0
390 + *
391 + * @param int $blog_id Blog to build from, 0 for the current site.
392 + * @param string $doc Document type from the rewrite.
393 + * @return array<string,mixed>|null
394 + */
395 + private static function discovery_document_for( int $blog_id, string $doc ): ?array {
396 + $switched = false;
397 +
398 + if ( $blog_id > 0 ) {
399 + switch_to_blog( $blog_id );
400 + $switched = true;
401 + }
402 +
403 + $data = null;
404 +
405 + try {
406 + if ( self::is_enabled() ) {
407 + $data = 'authorization-server' === $doc
408 + ? Mcp_OAuth::authorization_server_metadata()
409 + : Mcp_OAuth::protected_resource_metadata();
410 + }
411 + } finally {
412 + // A throw between the switch and the restore would leave the rest
413 + // of the request, including shutdown hooks, running against the
414 + // wrong site. Cheap to make impossible.
415 + if ( $switched ) {
416 + restore_current_blog();
417 + }
418 + }
419 +
420 + return $data;
421 + }
422 +
423 + /**
243 424 * Serve the MCP endpoint on the pretty path. Runs on parse_request so it
244 425 * fires before the main query, and short-circuits WP entirely.
245 426 *
246 427 * @param \WP $wp The WP request object.
@@ -248,16 +429,30 @@
248 429 */
249 430 public function maybe_handle_pretty_endpoint( $wp ): void {
250 431 // OAuth discovery documents (served at the site root).
251 432 if ( ! empty( $wp->query_vars[ self::WELLKNOWN_QUERY_VAR ] ) ) {
252 - if ( ! self::is_enabled() ) {
433 + // The path after the document type names the RESOURCE being
434 + // discovered, and it used to be discarded (#516). Resolve it to
435 + // the site that actually owns it, which on subdirectory multisite
436 + // is how /ca/thinkrank/mcp stops being answered by the main site
437 + // with endpoints that have no /ca/ in them.
438 + $target = self::resolve_wellknown_target( $wp );
439 +
440 + if ( null === $target ) {
253 441 status_header( 404 );
254 442 exit;
255 443 }
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();
444 +
445 + $data = self::discovery_document_for(
446 + $target,
447 + (string) $wp->query_vars[ self::WELLKNOWN_QUERY_VAR ]
448 + );
449 +
450 + if ( null === $data ) {
451 + status_header( 404 );
452 + exit;
453 + }
454 +
260 455 status_header( 200 );
261 456 header( 'Content-Type: application/json; charset=utf-8' );
262 457 // Discovery metadata is public + cacheable.
263 458 header( 'Cache-Control: public, max-age=3600' );