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 +212 -83 2.9.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
@@ -152,101 +178,197 @@
152 178 *
153 179 * @return void
154 180 */
155 181 public function add_rewrite(): void {
156 - // Token-in-URL form: /thinkrank/mcp/<token> — a single string the user
157 - // pastes into their AI client (no separate token field). The bare
158 - // /thinkrank/mcp still works with a Bearer token.
159 - add_rewrite_rule(
160 - '^thinkrank/mcp/([a-f0-9]{64})/?$',
161 - 'index.php?' . self::QUERY_VAR . '=1&' . self::TOKEN_QUERY_VAR . '=$matches[1]',
162 - 'top'
163 - );
164 - add_rewrite_rule( '^thinkrank/mcp/?$', 'index.php?' . self::QUERY_VAR . '=1', 'top' );
182 + $rules = self::rewrite_rules();
165 183
166 - // OAuth discovery documents. RFC 9728 §3.1 / RFC 8414 §3.1 place the
167 - // `.well-known` segment BEFORE the resource path, so our resource at
168 - // /thinkrank/mcp is discovered at the path-suffixed form:
169 - // /.well-known/oauth-protected-resource/thinkrank/mcp
170 - // /.well-known/oauth-authorization-server/thinkrank/mcp
171 - // The OAuth issuer is the path-based identifier home_url('/thinkrank/mcp')
172 - // (see Mcp_OAuth::issuer), so spec-compliant clients derive exactly
173 - // these URLs — and the rule stays specific to OUR path. That matters
174 - // for coexistence: another plugin serving its own MCP OAuth surface
175 - // (e.g. xSpeed) claims the generic `(?:/.*)?` root rule, and rewrite
176 - // rules are keyed by regex, so a shared broad rule would be silently
177 - // overwritten by whichever plugin registers last.
178 - add_rewrite_rule(
179 - '^\.well-known/oauth-(protected-resource|authorization-server)/thinkrank/mcp/?$',
180 - 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]',
181 - 'top'
182 - );
183 - // Root-form fallback for clients that only try the bare well-known
184 - // URL. Harmless when another plugin also registers this exact regex —
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.
195 - add_rewrite_rule(
196 - '^\.well-known/oauth-(protected-resource|authorization-server)(/.*)?/?$',
197 - 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]&' . self::WELLKNOWN_RESOURCE_QUERY_VAR . '=$matches[2]',
198 - 'top'
199 - );
200 - // Suffix form: <issuer>/.well-known/... . RFC 8414 specifies the
201 - // path-INSERT form above, but the older OpenID Connect Discovery
202 - // convention appends instead, and clients built on an OIDC library
203 - // try that shape first (sometimes only that shape). Serving both
204 - // costs two rules and removes a whole class of "server does not
205 - // implement OAuth" failures from clients that never fall back.
206 - add_rewrite_rule(
207 - '^thinkrank/mcp/\.well-known/oauth-(protected-resource|authorization-server)/?$',
208 - 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]',
209 - 'top'
210 - );
211 - add_rewrite_rule(
212 - '^thinkrank/mcp/\.well-known/openid-configuration/?$',
213 - 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=authorization-server',
214 - 'top'
215 - );
184 + foreach ( $rules as $regex => $query ) {
185 + add_rewrite_rule( $regex, $query, 'top' );
186 + }
216 187
217 - // Browser-facing OAuth consent page — served OUTSIDE REST so cookie
218 - // auth (is_user_logged_in) works after the wp-login round-trip.
219 - add_rewrite_rule( '^thinkrank/authorize/?$', 'index.php?' . self::AUTHORIZE_QUERY_VAR . '=1', 'top' );
220 -
221 188 // Self-heal: flush once if ANY of our rules is missing from the stored
222 189 // rewrite table, so the endpoints work without a manual permalink
223 190 // re-save (and newly added rules trigger a re-flush on upgrade).
224 - $expected = [
225 - '^thinkrank/mcp/([a-f0-9]{64})/?$',
226 - '^thinkrank/mcp/?$',
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)(/.*)?/?$',
233 - '^thinkrank/mcp/\.well-known/oauth-(protected-resource|authorization-server)/?$',
234 - '^thinkrank/mcp/\.well-known/openid-configuration/?$',
235 - '^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',
236 302 ];
237 - $rules = get_option( 'rewrite_rules' );
238 - if ( is_array( $rules ) ) {
239 - foreach ( $expected as $rule ) {
240 - if ( ! isset( $rules[ $rule ] ) ) {
241 - flush_rewrite_rules( false );
242 - break;
243 - }
244 - }
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]';
245 307 }
308 +
309 + return $rules;
246 310 }
247 311
248 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 + /**
249 371 * Register our query vars.
250 372 *
251 373 * @param string[] $vars Registered query vars.
252 374 * @return string[]
@@ -472,8 +594,15 @@
472 594
473 595 if ( empty( $wp->query_vars[ self::QUERY_VAR ] ) ) {
474 596 return;
475 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;
476 605
477 606 $request = new \WP_REST_Request( 'POST', '/' . self::NS . '/mcp' );
478 607 $request->set_header( 'content-type', 'application/json' );
479 608 // Carry the auth header + raw body from the live PHP request.