PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.3
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.3
4.9.3 4.9.2 4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 3.4.2 3.5.0 All 201 releases
← All changes | includes/Mcp/MCPManager.php +95 -12 4.9.0 → 4.9.3 View file →
@@ -91,8 +91,17 @@
91 91 */
92 92 const AUTHORIZE_QUERY_VAR = 'betterdocs_mcp_authorize';
93 93
94 94 /**
95 + * Bumped whenever the rewrite rule *set* changes shape, to force a single
96 + * flush that evicts rules retired in an earlier version. Stored against
97 + * `betterdocs_mcp_rewrite_ver`; see {@see self::maybe_flush()}.
98 + *
99 + * @since 4.9.3
100 + */
101 + const REWRITE_VER = '2';
102 +
103 + /**
95 104 * Whether a rewrite flush has already been triggered this request.
96 105 *
97 106 * @since 4.9.0
98 107 *
@@ -133,8 +142,15 @@
133 142 $this->self_test = $self_test;
134 143
135 144 add_action( 'init', [ $this, 'add_rewrite' ] );
136 145 add_filter( 'query_vars', [ $this, 'register_query_vars' ] );
146 + // Claim our own discovery URLs on `do_parse_request`, which runs before
147 + // the rewrite table is even consulted, so another plugin's broad
148 + // `.well-known/oauth-*` catch-all rewrite cannot answer BetterDocs' own
149 + // discovery URL with its `resource`. Scoped to `betterdocs/mcp` only —
150 + // this never intercepts anyone else's path. Priority 0 so it wins over a
151 + // rival that also hooks here late.
152 + add_filter( 'do_parse_request', [ $this, 'serve_own_discovery' ], 0 );
137 153 add_action( 'parse_request', [ $this, 'maybe_handle_pretty_endpoint' ] );
138 154 add_action( 'rest_api_init', [ $this, 'register_rest' ] );
139 155 add_action( 'admin_notices', [ $this, 'warn_when_runtime_missing' ] );
140 156
@@ -182,19 +198,18 @@
182 198 foreach ( self::rules() as $regex => $query ) {
183 199 add_rewrite_rule( $regex, $query, 'top' );
184 200 }
185 201
186 - // Root-form fallback, for clients that only ever try the bare
187 - // well-known URL. Harmless when another plugin registers the same
188 - // regex — last registrant wins, and our own clients use the
189 - // path-suffixed form above. Deliberately outside self::rules(), so a
190 - // plugin that took it from us does not make us flush on every request.
191 - add_rewrite_rule(
192 - '^\.well-known/oauth-(protected-resource|authorization-server)(?:/.*)?/?$',
193 - 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]',
194 - 'top'
195 - );
196 -
202 + // No broad `(?:/.*)?` catch-all: that regex is identical across every
203 + // plugin built on this transport, so it becomes a single shared rewrite
204 + // key whose winner answers *every* plugin's `/.well-known/oauth-*/*`
205 + // discovery URL — returning its own `resource` for a path it does not
206 + // own, which RFC 9728 clients reject on the exact-match check. Each rule
207 + // in self::rules() is scoped to `betterdocs/mcp`, so only our own
208 + // discovery URLs route here. The bare-root form is intentionally not
209 + // served: RFC 9728 clients derive the path-suffixed URL from the MCP
210 + // endpoint, and a suffix-less URL cannot disambiguate two MCP plugins on
211 + // one site anyway.
197 212 self::maybe_flush();
198 213 }
199 214
200 215 /**
@@ -231,8 +246,21 @@
231 246 if ( self::$flushed ) {
232 247 return;
233 248 }
234 249
250 + // One-time flush when the rule set changes shape between versions. The
251 + // missing-rule check below only *adds* rules; it never evicts one that
252 + // was removed — such as the retired `(?:/.*)?` catch-all, which would
253 + // otherwise linger in the stored table and keep answering other plugins'
254 + // discovery URLs until permalinks were re-saved by hand.
255 + if ( self::REWRITE_VER !== (string) get_option( 'betterdocs_mcp_rewrite_ver', '' ) ) {
256 + self::$flushed = true;
257 + flush_rewrite_rules( false );
258 + update_option( 'betterdocs_mcp_rewrite_ver', self::REWRITE_VER, false );
259 +
260 + return;
261 + }
262 +
235 263 $rules = get_option( 'rewrite_rules' );
236 264
237 265 if ( ! is_array( $rules ) ) {
238 266 return;
@@ -269,8 +297,52 @@
269 297 return $vars;
270 298 }
271 299
272 300 /**
301 + * Serve BetterDocs' own OAuth discovery documents straight from the request
302 + * URI, before WordPress matches any rewrite rule.
303 + *
304 + * This is what makes the discovery URLs hijack-proof: the rewrite table is a
305 + * flat, order-dependent list shared by every plugin, so a plugin whose broad
306 + * `.well-known/oauth-*` catch-all happens to sit above our path-specific rule
307 + * would otherwise answer our own URL with its `resource`. Matching the URI
308 + * here — on `do_parse_request`, before rules are consulted — sidesteps that
309 + * ordering entirely. The patterns are anchored to `betterdocs/mcp`, so this
310 + * only ever claims BetterDocs' own paths and never intercepts another
311 + * plugin's discovery URL. When MCP is off it does nothing and lets the
312 + * request fall through.
313 + *
314 + * @since 4.9.3
315 + *
316 + * @param bool $continue Whether WordPress should continue parsing the request.
317 + * @return bool The unchanged flag when this is not one of our URLs; otherwise
318 + * the response is emitted and the request exits.
319 + */
320 + public function serve_own_discovery( $continue ) {
321 + if ( ! self::is_enabled() ) {
322 + return $continue;
323 + }
324 +
325 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- path only, matched with a literal-anchored regex, never stored or output.
326 + $uri = isset( $_SERVER['REQUEST_URI'] ) ? (string) wp_unslash( $_SERVER['REQUEST_URI'] ) : '';
327 + $path = (string) wp_parse_url( $uri, PHP_URL_PATH );
328 +
329 + // RFC 9728 path-insert form and the OIDC suffix form both name a document.
330 + if (
331 + preg_match( '#/\.well-known/oauth-(protected-resource|authorization-server)/betterdocs/mcp/?$#', $path, $m )
332 + || preg_match( '#/betterdocs/mcp/\.well-known/oauth-(protected-resource|authorization-server)/?$#', $path, $m )
333 + ) {
334 + $this->emit_discovery( $m[1] );
335 + }
336 +
337 + if ( preg_match( '#/betterdocs/mcp/\.well-known/openid-configuration/?$#', $path ) ) {
338 + $this->emit_discovery( 'authorization-server' );
339 + }
340 +
341 + return $continue;
342 + }
343 +
344 + /**
273 345 * Serve the pretty paths.
274 346 *
275 347 * Runs on `parse_request`, before the main query, and short-circuits
276 348 * WordPress entirely.
@@ -349,9 +421,20 @@
349 421 * @param string $doc `protected-resource` or `authorization-server`.
350 422 * @return void
351 423 */
352 424 private function emit_discovery( $doc ) {
353 - if ( ! self::is_enabled() ) {
425 + // Only answer BetterDocs' own discovery URLs. Every rule that sets the
426 + // well-known query var carries `betterdocs/mcp` in its path, so a request
427 + // that lacks it reached here through some other plugin's catch-all
428 + // rewrite — 404 rather than hand back BetterDocs metadata for a resource
429 + // we do not own (which an RFC 9728 client would reject anyway). This also
430 + // guards the window after an upgrade, before a retired catch-all is
431 + // flushed out of the stored rewrite table.
432 + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- path only, compared with strpos, never stored or output.
433 + $uri = isset( $_SERVER['REQUEST_URI'] ) ? (string) wp_unslash( $_SERVER['REQUEST_URI'] ) : '';
434 + $path = (string) wp_parse_url( $uri, PHP_URL_PATH );
435 +
436 + if ( ! self::is_enabled() || false === strpos( $path, 'betterdocs/mcp' ) ) {
354 437 status_header( 404 );
355 438 exit;
356 439 }
357 440