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
betterdocs / includes / Mcp / MCPManager.php

MCPManager.php in BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot 4.9.3, at includes/Mcp/MCPManager.php

2,003 lines 76.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * MCP manager — rewrites, the pretty endpoint, discovery and the REST surface.
4 *
5 * @package BetterDocs
6 * @since 4.9.0
7 */
8
9 namespace WPDeveloper\BetterDocs\Mcp;
10
11 if ( ! defined( 'ABSPATH' ) ) {
12 exit; // Exit if accessed directly.
13 }
14
15 /**
16 * BetterDocs speaks MCP directly at this site's own URL, so the user pastes
17 * their own site into their AI client — endpoint plus token, or, for
18 * OAuth-capable clients, the URL and nothing else:
19 *
20 * https://thissite.com/betterdocs/mcp (pretty, via rewrite)
21 * https://thissite.com/betterdocs/mcp/<64-hex> (token in the path)
22 * https://thissite.com/wp-json/betterdocs/v1/mcp (always-on fallback)
23 *
24 * This class owns the transport: the rewrite rules, `parse_request` handling for
25 * the pretty paths, the discovery documents, and every REST route. The JSON-RPC
26 * itself is {@see MCPServer}; the tool surface is {@see MCPTools}; auth is
27 * {@see MCPPairing} or {@see MCPOAuth}.
28 *
29 * `enable_mcp` is the master switch. Off means the endpoint, the discovery
30 * documents and the OAuth register/token routes all refuse. It never gates
31 * ability registration, and it never gates `/mcp/health` (ADR-013) — a health
32 * report you can only read when the thing is already working is useless.
33 *
34 * Discovery is served four ways on purpose (ADR-014):
35 *
36 * - `/.well-known/oauth-{protected-resource,authorization-server}/betterdocs/mcp`
37 * — the RFC 9728 §3.1 / RFC 8414 §3.1 path-**insert** form, which is what a
38 * spec-compliant client derives from our path-based issuer.
39 * - the bare root form, as a fallback for clients that only try that.
40 * - `/betterdocs/mcp/.well-known/…` — the older OpenID Connect **suffix**
41 * convention; clients built on an OIDC library often try that shape first.
42 * - REST aliases under `betterdocs/v1`, which the 401 challenge points at, so a
43 * host that intercepts `/.well-known/` cannot break the handshake.
44 *
45 * The path-specific well-known rules matter for coexistence: rewrite rules are
46 * keyed by their regex, so two plugins sharing one broad rule would silently
47 * overwrite each other depending on registration order.
48 *
49 * @since 4.9.0
50 */
51 final class MCPManager {
52
53 /**
54 * REST namespace shared with the rest of the plugin.
55 *
56 * @since 4.9.0
57 */
58 const NS = 'betterdocs/v1';
59
60 /**
61 * Query var flagging a pretty `/betterdocs/mcp` request.
62 *
63 * @since 4.9.0
64 */
65 const QUERY_VAR = 'betterdocs_mcp';
66
67 /**
68 * Query var carrying the token when it arrives in the URL path.
69 *
70 * @since 4.9.0
71 */
72 const TOKEN_QUERY_VAR = 'betterdocs_mcp_token';
73
74 /**
75 * Query var flagging a `/.well-known/` OAuth discovery request.
76 *
77 * @since 4.9.0
78 */
79 const WELLKNOWN_QUERY_VAR = 'betterdocs_mcp_wellknown';
80
81 /**
82 * Query var flagging the browser-facing OAuth consent page.
83 *
84 * Served **outside** the REST API deliberately: a REST route only honours
85 * cookie auth when a REST nonce comes with it, and a browser arriving from
86 * `wp-login.php` carries the cookie and no nonce — so `is_user_logged_in()`
87 * would be false there and the consent screen would loop back to login for
88 * ever. A normal front-end URL sees ordinary cookie auth.
89 *
90 * @since 4.9.0
91 */
92 const AUTHORIZE_QUERY_VAR = 'betterdocs_mcp_authorize';
93
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 /**
104 * Whether a rewrite flush has already been triggered this request.
105 *
106 * @since 4.9.0
107 *
108 * @var bool
109 */
110 private static $flushed = false;
111
112 /**
113 * The side-effect-free report behind `GET /mcp/health`.
114 *
115 * @since 4.9.0
116 *
117 * @var MCPHealth
118 */
119 private $health;
120
121 /**
122 * The loopback ladder behind `POST /mcp/self-test`.
123 *
124 * @since 4.9.0
125 *
126 * @var MCPSelfTest
127 */
128 private $self_test;
129
130 /**
131 * Registers every hook. Resolved from the container in
132 * `Plugin::initialize()`, which runs on `init` at priority 0; the two
133 * diagnostics are autowired alongside it.
134 *
135 * @since 4.9.0
136 *
137 * @param MCPHealth $health Health reporter.
138 * @param MCPSelfTest $self_test Loopback self-test.
139 */
140 public function __construct( MCPHealth $health, MCPSelfTest $self_test ) {
141 $this->health = $health;
142 $this->self_test = $self_test;
143
144 add_action( 'init', [ $this, 'add_rewrite' ] );
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 );
153 add_action( 'parse_request', [ $this, 'maybe_handle_pretty_endpoint' ] );
154 add_action( 'rest_api_init', [ $this, 'register_rest' ] );
155 add_action( 'admin_notices', [ $this, 'warn_when_runtime_missing' ] );
156
157 // The page's master switch writes through BetterDocs' own settings
158 // route, so this is where "MCP was just turned on" is observable. Mint
159 // there as well as on the first status read, so the token exists before
160 // the page asks for it (ADR-056).
161 add_action( 'betterdocs::settings::saved', [ $this, 'mint_on_enable' ], 10, 3 );
162
163 // Deleting or demoting a user kills the grants they made. Attached from
164 // here rather than from `Plugin` so the
165 // MCP transport and its grant lifecycle come up together.
166 MCPGrants::init();
167 }
168
169 /**
170 * Whether the MCP integration is switched on.
171 *
172 * @since 4.9.0
173 *
174 * @return bool
175 */
176 public static function is_enabled() {
177 if ( ! function_exists( 'betterdocs' ) ) {
178 return false;
179 }
180
181 $plugin = betterdocs();
182
183 if ( ! is_object( $plugin ) || ! isset( $plugin->settings ) || ! is_object( $plugin->settings ) ) {
184 return false;
185 }
186
187 return (bool) $plugin->settings->get( 'enable_mcp', false );
188 }
189
190 /**
191 * Register the rewrite rules, and self-heal the rewrite table.
192 *
193 * @since 4.9.0
194 *
195 * @return void
196 */
197 public function add_rewrite() {
198 foreach ( self::rules() as $regex => $query ) {
199 add_rewrite_rule( $regex, $query, 'top' );
200 }
201
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.
212 self::maybe_flush();
213 }
214
215 /**
216 * The rewrite rules this plugin owns, as `regex => query`.
217 *
218 * @since 4.9.0
219 *
220 * @return array
221 */
222 private static function rules() {
223 return [
224 // Token-in-URL form: one string the user pastes into a client that
225 // has no separate token field. The bare path still takes a Bearer
226 // header.
227 '^betterdocs/mcp/([a-f0-9]{64})/?$' => 'index.php?' . self::QUERY_VAR . '=1&' . self::TOKEN_QUERY_VAR . '=$matches[1]',
228 '^betterdocs/mcp/?$' => 'index.php?' . self::QUERY_VAR . '=1',
229 '^\.well-known/oauth-(protected-resource|authorization-server)/betterdocs/mcp/?$' => 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]',
230 '^betterdocs/mcp/\.well-known/oauth-(protected-resource|authorization-server)/?$' => 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]',
231 '^betterdocs/mcp/\.well-known/openid-configuration/?$' => 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=authorization-server',
232 '^betterdocs/authorize/?$' => 'index.php?' . self::AUTHORIZE_QUERY_VAR . '=1'
233 ];
234 }
235
236 /**
237 * Flush once if any rule of ours is missing from the stored table, so the
238 * endpoints work without a manual permalink re-save — and so a rule added in
239 * a later version installs itself on upgrade.
240 *
241 * @since 4.9.0
242 *
243 * @return void
244 */
245 private static function maybe_flush() {
246 if ( self::$flushed ) {
247 return;
248 }
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
263 $rules = get_option( 'rewrite_rules' );
264
265 if ( ! is_array( $rules ) ) {
266 return;
267 }
268
269 foreach ( array_keys( self::rules() ) as $regex ) {
270 if ( ! isset( $rules[ $regex ] ) ) {
271 self::$flushed = true;
272 flush_rewrite_rules( false );
273
274 return;
275 }
276 }
277 }
278
279 /**
280 * Register our query vars.
281 *
282 * @since 4.9.0
283 *
284 * @param string[] $vars Registered query vars.
285 * @return string[]
286 */
287 public function register_query_vars( $vars ) {
288 if ( ! is_array( $vars ) ) {
289 return $vars;
290 }
291
292 $vars[] = self::QUERY_VAR;
293 $vars[] = self::TOKEN_QUERY_VAR;
294 $vars[] = self::WELLKNOWN_QUERY_VAR;
295 $vars[] = self::AUTHORIZE_QUERY_VAR;
296
297 return $vars;
298 }
299
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 /**
345 * Serve the pretty paths.
346 *
347 * Runs on `parse_request`, before the main query, and short-circuits
348 * WordPress entirely.
349 *
350 * @since 4.9.0
351 *
352 * @param \WP $wp The WP request object.
353 * @return void
354 */
355 public function maybe_handle_pretty_endpoint( $wp ) {
356 $vars = isset( $wp->query_vars ) && is_array( $wp->query_vars ) ? $wp->query_vars : [];
357
358 if ( ! empty( $vars[ self::WELLKNOWN_QUERY_VAR ] ) ) {
359 $this->emit_discovery( (string) $vars[ self::WELLKNOWN_QUERY_VAR ] );
360
361 return;
362 }
363
364 if ( ! empty( $vars[ self::AUTHORIZE_QUERY_VAR ] ) ) {
365 // The master switch is checked inside, so a switched-off site
366 // answers with the same branded page as every other refusal
367 // rather than a bare status line.
368 $this->handle_authorize_page();
369
370 return;
371 }
372
373 if ( empty( $vars[ self::QUERY_VAR ] ) ) {
374 return;
375 }
376
377 // We never open an SSE stream, so there is nothing to GET here. Say so
378 // with the method the client should have used, rather than letting the
379 // JSON-RPC layer answer a parse error to an empty body.
380 if ( 'POST' !== strtoupper( (string) self::request_method() ) ) {
381 status_header( 405 );
382 header( 'Allow: POST' );
383 header( 'Content-Type: application/json; charset=utf-8' );
384 header( 'Cache-Control: no-store, private' );
385 echo wp_json_encode(
386 [
387 'error' => 'method_not_allowed',
388 'message' => 'The BetterDocs MCP endpoint accepts POST only.'
389 ]
390 );
391 exit;
392 }
393
394 $request = new \WP_REST_Request( 'POST', '/' . self::NS . '/mcp' );
395 $request->set_header( 'content-type', 'application/json' );
396
397 $auth = self::server_header( 'authorization' );
398
399 if ( null !== $auth ) {
400 $request->set_header( 'authorization', $auth );
401 }
402
403 // A token in the path is surfaced as a Bearer header, so there is one
404 // place that reads a credential. A real header, if also sent, wins.
405 $path_token = isset( $vars[ self::TOKEN_QUERY_VAR ] ) ? (string) $vars[ self::TOKEN_QUERY_VAR ] : '';
406
407 if ( '' !== $path_token && '' === (string) $request->get_header( 'authorization' ) ) {
408 $request->set_header( 'authorization', 'Bearer ' . $path_token );
409 }
410
411 $request->set_body( (string) file_get_contents( 'php://input' ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reading the raw request body; there is no WordPress API for it.
412
413 $this->emit_json( MCPServer::handle( $request ) );
414 }
415
416 /**
417 * Emit a discovery document from the pretty path.
418 *
419 * @since 4.9.0
420 *
421 * @param string $doc `protected-resource` or `authorization-server`.
422 * @return void
423 */
424 private function emit_discovery( $doc ) {
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' ) ) {
437 status_header( 404 );
438 exit;
439 }
440
441 status_header( 200 );
442 header( 'Content-Type: application/json; charset=utf-8' );
443 // Discovery metadata is public and stable — unlike everything else this
444 // class serves, it is safe to cache and expensive to re-fetch.
445 header( 'Cache-Control: public, max-age=3600' );
446
447 echo wp_json_encode( self::discovery_document( $doc ) );
448 exit;
449 }
450
451 /**
452 * The discovery document for a name.
453 *
454 * @since 4.9.0
455 *
456 * @param string $doc `protected-resource` or `authorization-server`.
457 * @return array
458 */
459 private static function discovery_document( $doc ) {
460 return 'authorization-server' === $doc
461 ? MCPOAuth::authorization_server_metadata()
462 : MCPOAuth::protected_resource_metadata();
463 }
464
465 /**
466 * The browser-facing OAuth consent page.
467 *
468 * Served through a rewrite rather than as a REST route, so ordinary cookie
469 * authentication works after the `wp-login.php` round trip — see
470 * {@see self::AUTHORIZE_QUERY_VAR} for why a REST route cannot.
471 *
472 * The order of the checks is deliberate. The master switch comes first, then
473 * the visitor, then the OAuth parameters: a logged-out prober therefore
474 * learns nothing about which client ids this site has registered, because it
475 * is sent to the login screen either way.
476 *
477 * `GET` renders the consent screen. `POST` is the nonce-checked submission:
478 * Approve issues a single-use authorization code and redirects to the
479 * client's registered `redirect_uri`; Deny redirects there with
480 * `error=access_denied`. Either way this method emits its own response —
481 * an HTML page or a redirect — and exits.
482 *
483 * @since 4.9.0
484 *
485 * @return void
486 */
487 public function handle_authorize_page() {
488 if ( ! self::is_enabled() ) {
489 $this->emit_oauth_error_page(
490 __( 'MCP is switched off', 'betterdocs' ),
491 __( 'This site is not accepting AI client connections right now. An administrator can switch MCP on under BetterDocs → MCP.', 'betterdocs' ),
492 404
493 );
494 }
495
496 $is_post = 'POST' === strtoupper( (string) self::request_method() );
497
498 // The parameters arrive on the query string for the consent link and in
499 // the body for the form submit. The nonce is verified below, before any
500 // POST value is acted on; the GET side is an ordinary OAuth
501 // authorization request and carries none by design.
502 // phpcs:disable WordPress.Security.NonceVerification
503 $source = $is_post ? $_POST : $_GET;
504 // phpcs:enable WordPress.Security.NonceVerification
505
506 $params = [];
507
508 foreach ( [ 'client_id', 'redirect_uri', 'response_type', 'code_challenge', 'code_challenge_method', 'scope', 'state', 'approve', 'deny', '_betterdocs_oauth_nonce' ] as $key ) {
509 $params[ $key ] = isset( $source[ $key ] ) ? sanitize_text_field( wp_unslash( $source[ $key ] ) ) : '';
510 }
511
512 if ( ! is_user_logged_in() ) {
513 $this->redirect_to_login();
514 }
515
516 // ADR-006: anyone who can edit docs may connect a client. They grant
517 // only their own powers — every ability re-checks its own capability —
518 // and the floor is the one `MCPServer` impersonates against, so a
519 // grant approved here can never be dead on arrival.
520 if ( ! current_user_can( MCPServer::IMPERSONATION_CAPABILITY ) ) {
521 $this->emit_oauth_error_page(
522 __( 'This account cannot connect an AI client', 'betterdocs' ),
523 sprintf(
524 /* translators: %s: the required WordPress capability, e.g. edit_docs. */
525 __( 'Your account can\'t connect an AI client to BetterDocs: it needs the "%s" capability. Ask an administrator, or use bd-get-status\'s capability list.', 'betterdocs' ),
526 MCPServer::IMPERSONATION_CAPABILITY
527 ),
528 403
529 );
530 }
531
532 $req = MCPOAuth::validate_authorize_request( $params );
533
534 if ( is_wp_error( $req ) ) {
535 $data = $req->get_error_data();
536 $redirectable = is_array( $data ) && ! empty( $data['redirectable'] );
537
538 // Report the error back to the client only when the destination is
539 // one this site registered for it. An unknown client, or a
540 // redirect_uri that matches nothing, never gets a redirect — that
541 // is the open-redirect guard.
542 if ( $redirectable && '' !== $params['redirect_uri'] ) {
543 $this->redirect_error( $params['redirect_uri'], $req->get_error_code(), $req->get_error_message(), $params['state'] );
544 }
545
546 $this->emit_oauth_error_page( __( 'Could not authorize', 'betterdocs' ), $req->get_error_message(), 400 );
547 }
548
549 if ( $is_post ) {
550 if ( ! wp_verify_nonce( $params['_betterdocs_oauth_nonce'], 'betterdocs_oauth_consent' ) ) {
551 $this->emit_oauth_error_page(
552 __( 'Security check failed', 'betterdocs' ),
553 __( 'This consent form is no longer valid. Start the connection again from your AI client.', 'betterdocs' ),
554 403
555 );
556 }
557
558 if ( '' === $params['approve'] ) {
559 $this->redirect_error( $req['redirect_uri'], 'access_denied', __( 'The user denied the request.', 'betterdocs' ), $req['state'] );
560 }
561
562 $this->redirect_success( $req['redirect_uri'], MCPOAuth::issue_code( $req, get_current_user_id() ), $req['state'] );
563 }
564
565 $this->emit_consent_screen( $req );
566 }
567
568 /**
569 * The absolute URL of the authorize request being served.
570 *
571 * Used as the return address for the `wp-login.php` round trip.
572 * `home_url()` re-anchors the path on this site, so a crafted
573 * `REQUEST_URI` cannot turn the login redirect into an off-site one.
574 *
575 * @since 4.9.0
576 *
577 * @return string
578 */
579 private function current_authorize_url() {
580 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- rebuilding this site's own URL; home_url() anchors it to this host and wp_login_url() escapes it.
581 $uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '';
582
583 return home_url( $uri );
584 }
585
586 /**
587 * Send an anonymous visitor to wp-login, returning here afterwards.
588 *
589 * @since 4.9.0
590 *
591 * @return void
592 */
593 private function redirect_to_login() {
594 wp_safe_redirect( wp_login_url( $this->current_authorize_url() ) );
595 exit;
596 }
597
598 /**
599 * Redirect back to the client with the authorization code.
600 *
601 * @since 4.9.0
602 *
603 * @param string $redirect_uri The client's validated redirect URI.
604 * @param string $code The authorization code.
605 * @param string $state The client's opaque state value.
606 * @return void
607 */
608 private function redirect_success( $redirect_uri, $code, $state ) {
609 $args = [ 'code' => rawurlencode( (string) $code ) ];
610
611 if ( '' !== (string) $state ) {
612 $args['state'] = rawurlencode( (string) $state );
613 }
614
615 // Not wp_safe_redirect(): `redirect_uri` is the client's own off-site
616 // callback, and `validate_authorize_request()` has already matched it
617 // against the set this site registered for that client.
618 wp_redirect( add_query_arg( $args, $redirect_uri ) ); // phpcs:ignore WordPress.Security.SafeRedirect -- validated OAuth redirect_uri.
619 exit;
620 }
621
622 /**
623 * Redirect back to the client with an OAuth error.
624 *
625 * @since 4.9.0
626 *
627 * @param string $redirect_uri The client's validated redirect URI.
628 * @param string $error OAuth error code.
629 * @param string $description Human-readable description.
630 * @param string $state The client's opaque state value.
631 * @return void
632 */
633 private function redirect_error( $redirect_uri, $error, $description, $state ) {
634 $args = [
635 'error' => rawurlencode( (string) $error ),
636 'error_description' => rawurlencode( (string) $description )
637 ];
638
639 if ( '' !== (string) $state ) {
640 $args['state'] = rawurlencode( (string) $state );
641 }
642
643 wp_redirect( add_query_arg( $args, $redirect_uri ) ); // phpcs:ignore WordPress.Security.SafeRedirect -- validated OAuth redirect_uri.
644 exit;
645 }
646
647 /**
648 * Render the consent screen and stop.
649 *
650 * Self-contained HTML: no theme, no admin chrome, no enqueued asset. This
651 * page is shown to someone arriving from an AI client, often mid-handshake,
652 * and it has to look the same on every site whatever the theme does.
653 *
654 * @since 4.9.0
655 *
656 * @param array $req Validated authorize parameters from {@see MCPOAuth::validate_authorize_request()}.
657 * @return void
658 */
659 private function emit_consent_screen( array $req ) {
660 $scope = isset( $req['scope'] ) ? (string) $req['scope'] : 'mcp';
661 $read_only = MCPOAuth::scope_is_read_only( $scope );
662 $user = wp_get_current_user();
663 $site_host = (string) wp_parse_url( home_url(), PHP_URL_HOST );
664
665 $client = isset( $req['client_name'] ) && '' !== (string) $req['client_name']
666 ? (string) $req['client_name']
667 : __( 'An AI assistant', 'betterdocs' );
668
669 // Where the authorization code is about to be sent, and everything this
670 // page is willing to say about it. Client registration is open
671 // (RFC 7591) and `client_name` is whatever the registrant typed, so the
672 // name alone proves nothing — anyone can register "Claude" pointing at
673 // their own callback. The callback host is the one field an attacker
674 // cannot choose freely, so the mark and the warning are both read from
675 // it and never from the name (ADR-064).
676 $callback = self::callback_identity( (string) $req['redirect_uri'] );
677
678 $access_label = $read_only
679 ? __( 'Read-only', 'betterdocs' )
680 : __( 'Read & write', 'betterdocs' );
681
682 $access_desc = $read_only
683 ? __( 'It can read your documentation, but it cannot change anything on this site.', 'betterdocs' )
684 : __( 'It acts as you: anything it creates, edits or deletes is recorded under your account.', 'betterdocs' );
685
686 $display_name = '' !== $user->display_name ? $user->display_name : $user->user_login;
687 $role_label = self::current_user_role_label();
688
689 // The initial, not `get_avatar()`: a Gravatar is a third-party image
690 // request, and this page must not tell anyone that this person is
691 // approving this app right now (ADR-064).
692 $initial = strtoupper( mb_substr( $display_name, 0, 1 ) );
693
694 // Preserve every OAuth parameter, so the POST re-validates identically.
695 $hidden = '';
696
697 foreach ( [ 'client_id', 'redirect_uri', 'code_challenge', 'scope', 'state' ] as $key ) {
698 $hidden .= sprintf(
699 '<input type="hidden" name="%1$s" value="%2$s" />',
700 esc_attr( $key ),
701 esc_attr( isset( $req[ $key ] ) ? (string) $req[ $key ] : '' )
702 );
703 }
704
705 // Re-asserted as literals: nothing else can have reached this point.
706 $hidden .= '<input type="hidden" name="code_challenge_method" value="S256" />';
707 $hidden .= '<input type="hidden" name="response_type" value="code" />';
708 $hidden .= '<input type="hidden" name="_betterdocs_oauth_nonce" value="' . esc_attr( wp_create_nonce( 'betterdocs_oauth_consent' ) ) . '" />';
709
710 $this->page_headers( 200 );
711
712 echo '<!doctype html><html lang="' . esc_attr( str_replace( '_', '-', get_locale() ) ) . '"><head><meta charset="utf-8">';
713 echo '<meta name="viewport" content="width=device-width,initial-scale=1"><meta name="referrer" content="no-referrer">';
714 echo '<title>' . esc_html__( 'Connect an AI client to BetterDocs', 'betterdocs' ) . '</title>';
715 echo '<style>' . self::page_styles() . '</style></head><body><main class="card consent">'; // phpcs:ignore WordPress.Security.EscapeOutput -- static stylesheet.
716
717 // --- Identity ---------------------------------------------------------
718 // Two tiles, the app and this site, joined by an arrow: who is asking,
719 // and what they are asking about. Every other fact on the page hangs off
720 // this one.
721 echo '<div class="lockup">';
722
723 // Every client tile is white with a hairline border and the glyph in its
724 // own colour: the two vendor marks carry their own fill from the file,
725 // and our own glyphs take the tint as their stroke. One treatment for
726 // the whole row, so a mark we drew never looks more or less endorsed
727 // than a mark a vendor drew (ADR-066, replacing ADR-065's split).
728 $tile_class = 'tile plain';
729 $tile_style = 'color:' . $callback['tint'];
730
731 echo '<div class="idt"><span class="' . esc_attr( $tile_class ) . '" style="' . esc_attr( $tile_style ) . '">'
732 . self::client_mark( $callback['mark'] ) // phpcs:ignore WordPress.Security.EscapeOutput -- static icon markup chosen by key.
733 . '</span><span class="idn"><b>' . esc_html( $client ) . '</b>';
734
735 if ( $callback['trusted'] ) {
736 echo '<span class="host">' . esc_html( $callback['host'] ) . '</span>';
737 } else {
738 echo '<span class="host warn">' . self::warn_mark() . esc_html( $callback['host'] ) . '</span>'; // phpcs:ignore WordPress.Security.EscapeOutput -- static icon markup; the host is esc_html'd.
739 }
740
741 echo '</span></div>';
742
743 echo '<div class="link" aria-hidden="true"><i></i><em>' . self::arrow_mark() . '</em><i></i></div>'; // phpcs:ignore WordPress.Security.EscapeOutput -- static icon markup.
744
745 echo '<div class="idt"><span class="tile bd">' . self::brand_mark( 30 ) . '</span>'; // phpcs:ignore WordPress.Security.EscapeOutput -- static icon markup.
746 echo '<span class="idn"><b>BetterDocs</b><span class="host">' . esc_html( $site_host ) . '</span></span></div>';
747
748 echo '</div>';
749
750 echo '<p class="idline">' . sprintf(
751 /* translators: 1: the AI client's name, escaped and wrapped in <strong>. 2: this site's host, escaped and wrapped in <strong>. */
752 esc_html__( '%1$s wants to work with the documentation on %2$s.', 'betterdocs' ),
753 '<strong>' . esc_html( $client ) . '</strong>',
754 '<strong>' . esc_html( $site_host ) . '</strong>'
755 ) . '</p>'; // phpcs:ignore WordPress.Security.EscapeOutput -- translated literal; both substitutions are esc_html'd above.
756
757 // --- Access -----------------------------------------------------------
758 echo '<div class="acc"><div class="top">';
759 echo '<span class="badge' . ( $read_only ? ' ro' : '' ) . '">' . esc_html( $access_label ) . '</span>';
760 echo '<p>' . esc_html( $access_desc ) . '</p>';
761 echo '</div></div>';
762
763 // --- Who is approving --------------------------------------------------
764 if ( '' !== $role_label ) {
765 $who = sprintf(
766 /* translators: 1: the login of the person approving, escaped and wrapped in <strong>. 2: their role. */
767 esc_html__( 'Signed in as %1$s &middot; %2$s', 'betterdocs' ),
768 '<strong>' . esc_html( $display_name ) . '</strong>',
769 esc_html( $role_label )
770 );
771 } else {
772 $who = sprintf(
773 /* translators: %s: the login of the person approving, escaped and wrapped in <strong>. */
774 esc_html__( 'Signed in as %s', 'betterdocs' ),
775 '<strong>' . esc_html( $display_name ) . '</strong>'
776 );
777 }
778
779 echo '<p class="who"><span class="avatar" aria-hidden="true">' . esc_html( $initial ) . '</span><span>'
780 . $who // phpcs:ignore WordPress.Security.EscapeOutput -- translated literal; every substitution was esc_html'd where it was built.
781 . '</span></p>';
782
783 // --- What it will be able to do ----------------------------------------
784 // A native <details>: the seven lines are one click away and still on the
785 // page, and the disclosure works with scripts off, which this page must.
786 $can = self::consent_capability_lines( $read_only );
787
788 if ( ! empty( $can ) ) {
789 echo '<details class="disc"><summary>' . sprintf(
790 /* translators: 1: the AI client's name. 2: how many things it will be able to do. */
791 esc_html__( 'What %1$s will be able to do (%2$d)', 'betterdocs' ),
792 esc_html( $client ),
793 count( $can )
794 ) . self::chevron_mark() . '</summary><ul class="caps">'; // phpcs:ignore WordPress.Security.EscapeOutput -- translated literal; the client name is esc_html'd and the icon is static.
795
796 foreach ( $can as $line ) {
797 echo '<li>' . self::tick_mark() . '<span>' . esc_html( $line ) . '</span></li>'; // phpcs:ignore WordPress.Security.EscapeOutput -- static icon markup; the line is esc_html'd.
798 }
799
800 echo '</ul></details>';
801 }
802
803 echo '<p class="note">' . self::lock_mark() . '<span>' . esc_html__( 'Secured with OAuth. You can revoke this app at any time under BetterDocs → MCP.', 'betterdocs' ) . '</span></p>'; // phpcs:ignore WordPress.Security.EscapeOutput -- static icon markup; the text is esc_html'd.
804
805 echo '<form method="post" action="' . esc_url( MCPOAuth::authorize_url() ) . '">';
806 echo $hidden; // phpcs:ignore WordPress.Security.EscapeOutput -- every value escaped with esc_attr() where it was built.
807 echo '<div class="actions">';
808 echo '<button type="submit" class="deny" name="deny" value="1">' . esc_html__( 'Deny', 'betterdocs' ) . '</button>';
809 echo '<button type="submit" class="approve" name="approve" value="1">' . esc_html__( 'Approve', 'betterdocs' ) . '</button>';
810 echo '</div></form></main></body></html>';
811
812 exit;
813 }
814
815 /**
816 * Render a standalone OAuth error page and stop.
817 *
818 * Never carries a code, a token or any other secret: this page is reachable
819 * by anyone who can guess the URL.
820 *
821 * @since 4.9.0
822 *
823 * @param string $title Short headline.
824 * @param string $message What went wrong, in plain language.
825 * @param int $status HTTP status code.
826 * @return void
827 */
828 private function emit_oauth_error_page( $title, $message, $status = 400 ) {
829 $this->page_headers( $status );
830
831 echo '<!doctype html><html lang="' . esc_attr( str_replace( '_', '-', get_locale() ) ) . '"><head><meta charset="utf-8">';
832 echo '<meta name="viewport" content="width=device-width,initial-scale=1"><meta name="referrer" content="no-referrer">';
833 echo '<title>' . esc_html__( 'Authorization error', 'betterdocs' ) . '</title>';
834 echo '<style>' . self::page_styles() . '</style></head><body><main class="card center">'; // phpcs:ignore WordPress.Security.EscapeOutput -- static stylesheet.
835 echo '<div class="brand"><span class="logo">' . self::brand_mark() . '</span><b>BetterDocs</b></div>'; // phpcs:ignore WordPress.Security.EscapeOutput -- static markup.
836 echo '<h1>' . esc_html( (string) $title ) . '</h1>';
837 echo '<p class="sub">' . esc_html( (string) $message ) . '</p>';
838 echo '</main></body></html>';
839
840 exit;
841 }
842
843 /**
844 * Status and security headers shared by both browser pages.
845 *
846 * `X-Frame-Options` and `Referrer-Policy` are the two that matter here: the
847 * consent screen grants an access token on one click, so it must never be
848 * framed, and the authorization code lands in a URL the browser must not
849 * leak onward in a `Referer`.
850 *
851 * @since 4.9.0
852 *
853 * @param int $status HTTP status code.
854 * @return void
855 */
856 private function page_headers( $status ) {
857 status_header( (int) $status );
858 header( 'Content-Type: text/html; charset=utf-8' );
859 header( 'Cache-Control: no-store' );
860 header( 'X-Frame-Options: DENY' );
861 header( 'Referrer-Policy: no-referrer' );
862 }
863
864 /**
865 * What this grant lets the app do, in the approving user's own terms.
866 *
867 * Derived from `current_user_can()` over the capabilities the abilities
868 * actually gate on, because an OAuth grant carries exactly the powers of the
869 * person approving it and nothing more (ADR-006).
870 *
871 * @since 4.9.0
872 *
873 * @param bool $read_only Whether the requested scope is read-only.
874 * @return string[] Human-readable lines; may be empty.
875 */
876 private static function consent_capability_lines( $read_only ) {
877 // [ capability, read-only phrasing, read-write phrasing ]. The
878 // capability is held in a variable on purpose: these are BetterDocs'
879 // own capabilities, not core's.
880 $map = [
881 [ 'edit_docs', __( 'Read docs, categories, tags and knowledge bases', 'betterdocs' ), __( 'Create and edit docs', 'betterdocs' ) ],
882 [ 'delete_docs', '', __( 'Trash and delete docs', 'betterdocs' ) ],
883 [ 'manage_doc_terms', '', __( 'Create and manage doc categories and tags', 'betterdocs' ) ],
884 [ 'manage_knowledge_base_terms', '', __( 'Create and manage knowledge bases (BetterDocs Pro)', 'betterdocs' ) ],
885 [ 'edit_others_docs', __( 'Read FAQs and FAQ groups', 'betterdocs' ), __( 'Create and manage FAQs and FAQ groups', 'betterdocs' ) ],
886 [ 'edit_docs_settings', __( 'Read BetterDocs settings (API keys stay hidden)', 'betterdocs' ), __( 'Read and change BetterDocs settings', 'betterdocs' ) ],
887 [ 'read_docs_analytics', __( 'Read documentation analytics', 'betterdocs' ), __( 'Read documentation analytics', 'betterdocs' ) ]
888 ];
889
890 $lines = [];
891
892 foreach ( $map as $entry ) {
893 list( $capability, $read_label, $write_label ) = $entry;
894
895 $label = $read_only ? $read_label : $write_label;
896
897 if ( '' === $label || ! current_user_can( $capability ) ) {
898 continue;
899 }
900
901 $lines[] = $label;
902 }
903
904 return array_values( array_unique( $lines ) );
905 }
906
907 /**
908 * The approving user's role, as a translated label.
909 *
910 * @since 4.9.0
911 *
912 * @return string Empty when the user somehow holds no role.
913 */
914 private static function current_user_role_label() {
915 $user = wp_get_current_user();
916
917 if ( ! isset( $user->roles ) || ! is_array( $user->roles ) || empty( $user->roles ) ) {
918 return '';
919 }
920
921 $slug = (string) reset( $user->roles );
922 $roles = wp_roles();
923 $names = is_object( $roles ) && method_exists( $roles, 'get_names' ) ? $roles->get_names() : [];
924
925 return isset( $names[ $slug ] ) ? translate_user_role( $names[ $slug ] ) : $slug;
926 }
927
928 /**
929 * The BetterDocs mark, inlined.
930 *
931 * Inline rather than an `<img>` from `assets/`: this page must render
932 * identically with no second request, on a site whose asset URLs may be
933 * behind a CDN or an offline dev host.
934 *
935 * @since 4.9.0
936 *
937 * @param int $size Edge length in pixels. 22 in the error page's header
938 * lockup, 30 in the consent screen's identity tile.
939 * @return string
940 */
941 private static function brand_mark( $size = 22 ) {
942 $size = (int) $size;
943
944 return '<svg width="' . $size . '" height="' . $size . '" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">'
945 . '<path d="M7.60796 6.34451H11.6196C12.011 6.34451 12.3289 6.01678 12.3289 5.61342C12.3289 5.21006 12.011 4.88232 11.6196 4.88232H7.60796C7.21658 4.88232 6.89859 5.21006 6.89859 5.61342C6.89043 6.01678 7.21658 6.34451 7.60796 6.34451Z" fill="#fff"/>'
946 . '<path d="M5.99453 9.19326H10.0061C10.3975 9.19326 10.7155 8.86553 10.7155 8.46217C10.7155 8.05881 10.3975 7.73108 10.0061 7.73108H5.99453C5.60315 7.73108 5.28516 8.05881 5.28516 8.46217C5.28516 8.86553 5.60315 9.19326 5.99453 9.19326Z" fill="#fff"/>'
947 . '<path d="M9.07685 11.3698C9.07685 10.9664 8.75885 10.6387 8.36747 10.6387H4.35586C3.96448 10.6387 3.64648 10.9664 3.64648 11.3698C3.64648 11.7731 3.96448 12.1009 4.35586 12.1009H8.36747C8.75885 12.1009 9.07685 11.7731 9.07685 11.3698Z" fill="#fff"/>'
948 . '<path d="M14.5798 8.0084C14.5554 7.94958 14.5228 7.90756 14.4901 7.85714L15.5583 5.95798C16.1453 4.92437 16.1453 3.68908 15.5664 2.65546C14.9875 1.62185 13.952 1 12.786 1H7.94273C6.80936 1 5.74938 1.63025 5.17047 2.64706L0.441324 11.042C-0.145742 12.0756 -0.145742 13.3109 0.43317 14.3445C1.01208 15.3782 2.0476 16 3.21358 16H11.5059C12.9817 16 14.3352 15.2269 15.118 13.9412C15.9089 12.6555 15.9904 11.0588 15.3544 9.68908L14.5798 8.0084ZM4.43664 14.4454H3.21358C2.5939 14.4454 2.0476 14.1176 1.73776 13.563C1.42792 13.0168 1.43608 12.3613 1.74592 11.8067L6.47506 3.41176C6.77675 2.87395 7.34751 2.53782 7.95088 2.53782H12.7942C13.4139 2.53782 13.9602 2.86555 14.27 3.42017C14.5798 3.96639 14.5717 4.62185 14.2618 5.17647L9.52454 13.5714C9.22286 14.1092 8.66025 14.4454 8.05688 14.4454H4.43664ZM13.8542 13.1092C13.3486 13.9412 12.468 14.4454 11.514 14.4454H10.7721C10.7884 14.4118 10.8128 14.3866 10.8291 14.3529L13.5932 9.45378L14.0172 10.3529C14.4168 11.2437 14.3597 12.2773 13.8542 13.1092Z" fill="#fff"/>'
949 . '</svg>';
950 }
951
952 /**
953 * A small check mark for the capability list.
954 *
955 * @since 4.9.0
956 *
957 * @return string
958 */
959 private static function tick_mark() {
960 return '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><polyline points="20 6 9 17 4 12"/></svg>';
961 }
962
963 /**
964 * A small padlock for the footer note.
965 *
966 * @since 4.9.0
967 *
968 * @return string
969 */
970 private static function lock_mark() {
971 return '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>';
972 }
973
974 /**
975 * The mark for one client, by key.
976 *
977 * Two kinds of drawing live here. `claude` and `openai` are the vendors' own
978 * published logo files, used exactly as published — path, viewBox and fill
979 * unchanged — each keeping its own colour on a white tile, which is why
980 * neither uses `currentColor` (ADR-066). `cursor`, `code` and `generic` are
981 * our own glyphs on the shared 24-unit grid and take the surrounding text
982 * colour. `react-src/admin/mcp/components/Icons.js` carries the same five
983 * marks for the admin page, so a client looks the same wherever it is drawn:
984 * change one and change both.
985 *
986 * Every mark is inline and self-contained: **no remote image may ever be
987 * fetched or referenced here** — it would leak the visit and the visitor's
988 * IP to a third party at exactly the moment they are deciding whether to
989 * trust that third party.
990 *
991 * @since 4.9.0
992 *
993 * @param string $key One of `claude`, `openai`, `cursor`, `code`, `generic`.
994 * @return string
995 */
996 private static function client_mark( $key ) {
997 // The vendors' own files, kept verbatim. Do not recolour, re-draw or
998 // re-grid them: an approximation of somebody else's logo is precisely
999 // what ADR-066 exists to undo.
1000 $files = [
1001 'claude' => [ '0 0 100 100', 'hsl(14.8, 63.1%, 59.6%)', 'm19.6 66.5 19.7-11 .3-1-.3-.5h-1l-3.3-.2-11.2-.3L14 53l-9.5-.5-2.4-.5L0 49l.2-1.5 2-1.3 2.9.2 6.3.5 9.5.6 6.9.4L38 49.1h1.6l.2-.7-.5-.4-.4-.4L29 41l-10.6-7-5.6-4.1-3-2-1.5-2-.6-4.2 2.7-3 3.7.3.9.2 3.7 2.9 8 6.1L37 36l1.5 1.2.6-.4.1-.3-.7-1.1L33 25l-6-10.4-2.7-4.3-.7-2.6c-.3-1-.4-2-.4-3l3-4.2L28 0l4.2.6L33.8 2l2.6 6 4.1 9.3L47 29.9l2 3.8 1 3.4.3 1h.7v-.5l.5-7.2 1-8.7 1-11.2.3-3.2 1.6-3.8 3-2L61 2.6l2 2.9-.3 1.8-1.1 7.7L59 27.1l-1.5 8.2h.9l1-1.1 4.1-5.4 6.9-8.6 3-3.5L77 13l2.3-1.8h4.3l3.1 4.7-1.4 4.9-4.4 5.6-3.7 4.7-5.3 7.1-3.2 5.7.3.4h.7l12-2.6 6.4-1.1 7.6-1.3 3.5 1.6.4 1.6-1.4 3.4-8.2 2-9.6 2-14.3 3.3-.2.1.2.3 6.4.6 2.8.2h6.8l12.6 1 3.3 2 1.9 2.7-.3 2-5.1 2.6-6.8-1.6-16-3.8-5.4-1.3h-.8v.4l4.6 4.5 8.3 7.5L89 80.1l.5 2.4-1.3 2-1.4-.2-9.2-7-3.6-3-8-6.8h-.5v.7l1.8 2.7 9.8 14.7.5 4.5-.7 1.4-2.6 1-2.7-.6-5.8-8-6-9-4.7-8.2-.5.4-2.9 30.2-1.3 1.5-3 1.2-2.5-2-1.4-3 1.4-6.2 1.6-8 1.3-6.4 1.2-7.9.7-2.6v-.2H49L43 72l-9 12.3-7.2 7.6-1.7.7-3-1.5.3-2.8L24 86l10-12.8 6-7.9 4-4.6-.1-.5h-.3L17.2 77.4l-4.7.6-2-2 .2-3 1-1 8-5.5Z' ],
1002 'openai' => [ '0 0 320 320', '#000000', 'm297.06 130.97c7.26-21.79 4.76-45.66-6.85-65.48-17.46-30.4-52.56-46.04-86.84-38.68-15.25-17.18-37.16-26.95-60.13-26.81-35.04-.08-66.13 22.48-76.91 55.82-22.51 4.61-41.94 18.7-53.31 38.67-17.59 30.32-13.58 68.54 9.92 94.54-7.26 21.79-4.76 45.66 6.85 65.48 17.46 30.4 52.56 46.04 86.84 38.68 15.24 17.18 37.16 26.95 60.13 26.8 35.06.09 66.16-22.49 76.94-55.86 22.51-4.61 41.94-18.7 53.31-38.67 17.57-30.32 13.55-68.51-9.94-94.51zm-120.28 168.11c-14.03.02-27.62-4.89-38.39-13.88.49-.26 1.34-.73 1.89-1.07l63.72-36.8c3.26-1.85 5.26-5.32 5.24-9.07v-89.83l26.93 15.55c.29.14.48.42.52.74v74.39c-.04 33.08-26.83 59.9-59.91 59.97zm-128.84-55.03c-7.03-12.14-9.56-26.37-7.15-40.18.47.28 1.3.79 1.89 1.13l63.72 36.8c3.23 1.89 7.23 1.89 10.47 0l77.79-44.92v31.1c.02.32-.13.63-.38.83l-64.41 37.19c-28.69 16.52-65.33 6.7-81.92-21.95zm-16.77-139.09c7-12.16 18.05-21.46 31.21-26.29 0 .55-.03 1.52-.03 2.2v73.61c-.02 3.74 1.98 7.21 5.23 9.06l77.79 44.91-26.93 15.55c-.27.18-.61.21-.91.08l-64.42-37.22c-28.63-16.58-38.45-53.21-21.95-81.89zm221.26 51.49-77.79-44.92 26.93-15.54c.27-.18.61-.21.91-.08l64.42 37.19c28.68 16.57 38.51 53.26 21.94 81.94-7.01 12.14-18.05 21.44-31.2 26.28v-75.81c.03-3.74-1.96-7.2-5.2-9.06zm26.8-40.34c-.47-.29-1.3-.79-1.89-1.13l-63.72-36.8c-3.23-1.89-7.23-1.89-10.47 0l-77.79 44.92v-31.1c-.02-.32.13-.63.38-.83l64.41-37.16c28.69-16.55 65.37-6.7 81.91 22 6.99 12.12 9.52 26.31 7.15 40.1zm-168.51 55.43-26.94-15.55c-.29-.14-.48-.42-.52-.74v-74.39c.02-33.12 26.89-59.96 60.01-59.94 14.01 0 27.57 4.92 38.34 13.88-.49.26-1.33.73-1.89 1.07l-63.72 36.8c-3.26 1.85-5.26 5.31-5.24 9.06l-.04 89.79zm14.63-31.54 34.65-20.01 34.65 20v40.01l-34.65 20-34.65-20z' ]
1003 ];
1004
1005 if ( isset( $files[ $key ] ) ) {
1006 $f = $files[ $key ];
1007
1008 return '<svg width="30" height="30" viewBox="' . $f[0] . '" fill="' . $f[1] . '" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">'
1009 . '<path d="' . $f[2] . '"/></svg>';
1010 }
1011
1012 // Cursor's arrow is a closed shape, drawn filled rather than as a
1013 // hairline outline — the same drawing the vendor's own icon uses, and
1014 // the shape reads at 28px where a 1.8-unit stroke would not.
1015 if ( 'cursor' === $key ) {
1016 return '<svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">'
1017 . '<path d="M4 3l16 7-6.6 2.4L11 20 4 3z"/></svg>';
1018 }
1019
1020 // [ stroke width, path data ] on the shared 24x24 grid.
1021 $strokes = [
1022 'code' => [ '1.8', '<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>' ],
1023 'generic' => [ '1.8', '<path d="M12 3v18M3 12h18M6.3 6.3l11.4 11.4M17.7 6.3L6.3 17.7"/>' ]
1024 ];
1025
1026 $icon = isset( $strokes[ $key ] ) ? $strokes[ $key ] : $strokes['generic'];
1027
1028 return '<svg width="28" height="28" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" stroke-width="'
1029 . $icon[0] . '" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">' . $icon[1] . '</svg>';
1030 }
1031
1032 /**
1033 * The warning triangle shown beside an untrusted callback host.
1034 *
1035 * @since 4.9.0
1036 *
1037 * @return string
1038 */
1039 private static function warn_mark() {
1040 return '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">'
1041 . '<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><path d="M12 9v4.5"/><path d="M12 17.2h.01"/></svg>';
1042 }
1043
1044 /**
1045 * The arrow joining the two tiles of the identity lockup.
1046 *
1047 * @since 4.9.0
1048 *
1049 * @return string
1050 */
1051 private static function arrow_mark() {
1052 return '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">'
1053 . '<path d="M4.5 12h14"/><path d="M13 6.5 18.5 12 13 17.5"/></svg>';
1054 }
1055
1056 /**
1057 * The chevron on the capability disclosure's summary.
1058 *
1059 * @since 4.9.0
1060 *
1061 * @return string
1062 */
1063 private static function chevron_mark() {
1064 return '<svg class="chev" width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">'
1065 . '<polyline points="6 9.5 12 15.5 18 9.5"/></svg>';
1066 }
1067
1068 /**
1069 * Everything the consent screen may say about a callback URL.
1070 *
1071 * Registration (RFC 7591, {@see MCPOAuth::register_client()}) stores three
1072 * fields — `name`, `redirect_uris`, `created`. `logo_uri` is deliberately
1073 * not among them and must not become one: it is attacker-supplied, so a
1074 * hostile client could register itself with Anthropic's logo and wear it on
1075 * this screen, and fetching it would tell a third party the exact moment
1076 * this person sat down to approve an app, from their IP (ADR-064).
1077 *
1078 * That leaves the callback host as the only identity a client cannot choose
1079 * freely — it is where the authorization code is about to be sent, so a
1080 * client that lies about it gets nothing. Both the mark and the warning are
1081 * read from it.
1082 *
1083 * @since 4.9.0
1084 *
1085 * @param string $redirect_uri The validated `redirect_uri` from the request.
1086 * @return array {
1087 * @type string $host What to print: host, plus `:port` when there is one.
1088 * @type string $mark Mark key for {@see self::client_mark()}.
1089 * @type string $tint Brand colour for the tile, `#rrggbb`.
1090 * @type bool $solid Whether the mark is a vendor's own logo file
1091 * rather than one of our glyphs. Retained for the
1092 * tested contract; this screen gives every tile
1093 * the same treatment now (ADR-066).
1094 * @type bool $trusted Whether the host may be stated quietly.
1095 * }
1096 */
1097 private static function callback_identity( $redirect_uri ) {
1098 $redirect_uri = (string) $redirect_uri;
1099 $scheme = strtolower( (string) wp_parse_url( $redirect_uri, PHP_URL_SCHEME ) );
1100 $host = strtolower( (string) wp_parse_url( $redirect_uri, PHP_URL_HOST ) );
1101 $port = wp_parse_url( $redirect_uri, PHP_URL_PORT );
1102
1103 // Native clients may register a custom scheme with no host at all
1104 // (`myapp:/cb`). Nothing is known about those, so they get the generic
1105 // mark and the warning, and the whole URI is what we can honestly print.
1106 $display = '' === $host ? $redirect_uri : $host;
1107
1108 if ( '' !== $host && $port ) {
1109 // The port is part of the identity: two locally-running apps differ
1110 // by nothing else.
1111 $display .= ':' . (int) $port;
1112 }
1113
1114 $mark = self::client_mark_for_host( $host );
1115 $loopback = 'code' === $mark['mark'];
1116
1117 // Untrusted is the default, and three separate things earn it: a host
1118 // this site does not recognise, a raw IP literal off the loopback, and
1119 // cleartext `http://` anywhere but the loopback. A recognised host over
1120 // https is trusted; so is the editor/CLI loopback flow, which is http
1121 // by nature and never leaves the machine.
1122 $trusted = true;
1123
1124 if ( ! $mark['known'] ) {
1125 $trusted = false;
1126 } elseif ( ! $loopback && self::is_ip_literal( $host ) ) {
1127 $trusted = false;
1128 } elseif ( ! $loopback && 'https' !== $scheme ) {
1129 $trusted = false;
1130 }
1131
1132 return [
1133 'host' => $display,
1134 'mark' => $mark['mark'],
1135 'tint' => $mark['tint'],
1136 'solid' => $mark['solid'],
1137 'trusted' => $trusted
1138 ];
1139 }
1140
1141 /**
1142 * Pick a client mark from a callback host.
1143 *
1144 * Matched **exactly or on a dot boundary**, never as a bare substring:
1145 * `claude.ai` and `foo.claude.ai` are Claude, while
1146 * `claude.ai.attacker.example`, `notclaude.ai` and `claude.ai.` are not and
1147 * fall through to the generic mark. A screen that gets this wrong tells the
1148 * person about to click Approve a lie about who they are talking to.
1149 *
1150 * @since 4.9.0
1151 *
1152 * @param string $host Bare callback host, lower-cased, no port.
1153 * @return array {
1154 * @type string $mark One of `claude`, `openai`, `cursor`, `code`, `generic`.
1155 * @type string $tint Brand colour, `#rrggbb`.
1156 * @type bool $solid Whether the mark is a vendor's own logo file.
1157 * @type bool $known Whether the host was recognised at all.
1158 * }
1159 */
1160 private static function client_mark_for_host( $host ) {
1161 $host = strtolower( (string) $host );
1162
1163 // Host => [ mark, tint, solid ]. Nothing here is keyed on the client's
1164 // *name* on purpose (ADR-064). `solid` marks the hosts whose glyph is a
1165 // vendor's own logo file rather than one of ours; every tile on this
1166 // screen is white either way now (ADR-066), so nothing here reads it —
1167 // it stays because `callback_identity()`'s shape is pinned by tests.
1168 $map = [
1169 'claude.ai' => [ 'claude', '#d97757', true ],
1170 'chatgpt.com' => [ 'openai', '#000000', true ],
1171 'openai.com' => [ 'openai', '#000000', true ],
1172 'cursor.sh' => [ 'cursor', '#0f172a', true ],
1173 'localhost' => [ 'code', '#0098ff', false ],
1174 '127.0.0.1' => [ 'code', '#0098ff', false ],
1175 '[::1]' => [ 'code', '#0098ff', false ]
1176 ];
1177
1178 foreach ( $map as $known => $triple ) {
1179 if ( self::host_matches( $host, $known ) ) {
1180 return [
1181 'mark' => $triple[0],
1182 'tint' => $triple[1],
1183 'solid' => $triple[2],
1184 'known' => true
1185 ];
1186 }
1187 }
1188
1189 return [
1190 'mark' => 'generic',
1191 'tint' => '#00b884',
1192 'solid' => false,
1193 'known' => false
1194 ];
1195 }
1196
1197 /**
1198 * Whether `$host` is `$known` itself or a subdomain of it.
1199 *
1200 * The whole point is the dot: a plain `strpos()` or a `str_ends_with()`
1201 * without it would hand `notclaude.ai` Claude's mark.
1202 *
1203 * @since 4.9.0
1204 *
1205 * @param string $host Candidate host, already lower-cased.
1206 * @param string $known Known host, lower-case.
1207 * @return bool
1208 */
1209 private static function host_matches( $host, $known ) {
1210 $host = (string) $host;
1211 $known = (string) $known;
1212
1213 if ( '' === $host || '' === $known ) {
1214 return false;
1215 }
1216
1217 if ( $host === $known ) {
1218 return true;
1219 }
1220
1221 // An IP literal has no subdomains. `evil.127.0.0.1` is a name somebody
1222 // else can own; it is not this machine, and it must not inherit the
1223 // loopback's trust.
1224 if ( self::is_ip_literal( $known ) ) {
1225 return false;
1226 }
1227
1228 return strlen( $host ) > strlen( $known )
1229 && substr( $host, - ( strlen( $known ) + 1 ) ) === '.' . $known;
1230 }
1231
1232 /**
1233 * Whether a host is a bare IP address rather than a name.
1234 *
1235 * `wp_parse_url()` hands back IPv6 hosts still wrapped in their brackets,
1236 * which `FILTER_VALIDATE_IP` will not take.
1237 *
1238 * @since 4.9.0
1239 *
1240 * @param string $host Bare host, no port.
1241 * @return bool
1242 */
1243 private static function is_ip_literal( $host ) {
1244 $host = trim( (string) $host, '[]' );
1245
1246 return '' !== $host && false !== filter_var( $host, FILTER_VALIDATE_IP );
1247 }
1248
1249 /**
1250 * The inline stylesheet shared by the consent and error pages.
1251 *
1252 * BetterDocs' own tokens (`docs/design-system.md`): brand green `#00b884`,
1253 * cards at radius 12–16px, `--text-color-*` neutrals.
1254 *
1255 * @since 4.9.0
1256 *
1257 * @return string
1258 */
1259 private static function page_styles() {
1260 return '*{box-sizing:border-box}'
1261 . 'body{font:15px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;'
1262 . 'background:#f7f8fa radial-gradient(900px 460px at 50% -12%,#ecfdf3,rgba(247,248,250,0)) no-repeat;'
1263 . 'color:#101828;margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}'
1264 . '.card{width:100%;max-width:460px;background:#fff;border:1px solid #e4e7ec;border-radius:16px;padding:28px;box-shadow:0 12px 40px rgba(16,24,40,.08)}'
1265 . '.card.center{text-align:center;max-width:440px}'
1266 . '.card.consent{max-width:440px}'
1267 . '.brand{display:flex;align-items:center;gap:10px;margin-bottom:20px}'
1268 . '.card.center .brand{justify-content:center}'
1269 . '.logo{width:36px;height:36px;border-radius:11px;display:inline-flex;align-items:center;justify-content:center;'
1270 . 'background:linear-gradient(135deg,#00c896,#00a877);box-shadow:0 6px 16px rgba(0,184,132,.32)}'
1271 . '.brand b{font-size:14px;font-weight:700;letter-spacing:.01em;color:#101828}'
1272 . 'h1{font-size:20px;line-height:1.3;font-weight:700;margin:0 0 6px}'
1273 . '.sub{color:#667085;font-size:13.5px;margin:0 0 20px}.sub strong{color:#101828;font-weight:600}'
1274 // -- the identity lockup: who is asking, and about what -------------
1275 . '.lockup{display:flex;align-items:flex-start;justify-content:center;margin:0 0 18px}'
1276 . '.idt{display:flex;flex-direction:column;align-items:center;gap:9px;width:116px}'
1277 . '.tile{width:56px;height:56px;border-radius:17px;display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto}'
1278 . '.tile.bd{background:linear-gradient(135deg,#00c896,#00a877);box-shadow:0 8px 20px rgba(0,184,132,.32)}'
1279 // A solid vendor tile sits beside the BetterDocs tile, which casts a
1280 // shadow; without one of its own the pair reads as two different
1281 // kinds of object.
1282 . '.tile.plain{background:#fff;border:1px solid #e4e7ec;box-shadow:0 6px 16px rgba(16,24,40,.10)}'
1283 . '.idn{display:flex;flex-direction:column;align-items:center;gap:3px}'
1284 . '.idn b{font-size:12px;font-weight:700;color:#344054}'
1285 . '.idn .host{font-size:12px;line-height:1.35;color:#667085;word-break:break-all}'
1286 . '.idn .host.warn{color:#b54708;font-weight:600;display:inline-flex;align-items:center;gap:4px;word-break:normal}'
1287 . '.idn .host.warn svg{flex:0 0 auto}'
1288 . '.link{display:flex;align-items:center;width:58px;margin-top:27px;color:#98a2b3}'
1289 . '.link i{flex:1;border-top:2px dotted #d0d5dd}'
1290 . '.link em{width:24px;height:24px;flex:0 0 auto;margin:0 4px;border-radius:50%;background:#fff;border:1px solid #e4e7ec;'
1291 . 'display:inline-flex;align-items:center;justify-content:center}'
1292 . '.idline{font-size:16px;line-height:1.45;color:#475467;text-align:center;margin:0 0 18px}'
1293 . '.idline strong{color:#101828;font-weight:700}'
1294 // -- the access block ------------------------------------------------
1295 . '.acc{border:1px solid #e4e7ec;border-radius:12px;overflow:hidden;background:#fff;margin:0 0 14px}'
1296 . '.acc .top{padding:14px 16px;background:#f7fefc}'
1297 . '.acc .top p{color:#475467;font-size:13px;margin:9px 0 0}'
1298 . '.badge{display:inline-flex;align-items:center;font-size:12px;font-weight:700;padding:3px 10px;border-radius:999px;'
1299 . 'background:#d1fadf;color:#027a48;border:1px solid #a6f4c5}'
1300 . '.badge.ro{background:#fef0c7;color:#b54708;border-color:#fdb022}'
1301 // -- who is approving ------------------------------------------------
1302 . '.who{display:flex;align-items:center;gap:9px;font-size:13px;color:#667085;margin:0 0 14px;padding:0 2px}'
1303 . '.who strong{color:#101828;font-weight:600}'
1304 . '.avatar{width:24px;height:24px;border-radius:50%;flex:0 0 auto;background:#eaecf0;color:#475467;font-size:11px;'
1305 . 'font-weight:700;display:inline-flex;align-items:center;justify-content:center}'
1306 // -- the capability disclosure (native <details>, no script) ----------
1307 . 'details.disc{border:1px solid #e4e7ec;border-radius:10px;margin:0 0 14px;background:#fff}'
1308 . 'details.disc>summary{list-style:none;cursor:pointer;padding:11px 14px;font-size:13.5px;font-weight:600;color:#344054;'
1309 . 'display:flex;align-items:center;gap:8px}'
1310 . 'details.disc>summary::-webkit-details-marker{display:none}'
1311 . 'details.disc>summary:focus-visible{outline:2px solid #00b884;outline-offset:2px;border-radius:9px}'
1312 . 'details.disc .chev{margin-left:auto;color:#98a2b3;transition:transform .15s}'
1313 . 'details.disc[open] .chev{transform:rotate(180deg)}'
1314 . 'details.disc .caps{margin:0;padding:2px 14px 14px}'
1315 . '.caps{list-style:none;margin:0 0 18px;padding:0;display:grid;gap:7px}'
1316 . '.caps li{display:flex;align-items:flex-start;gap:8px;font-size:13.5px;color:#344054}'
1317 . '.caps svg{color:#00b884;flex:0 0 auto;margin-top:3px}'
1318 // -- the footer note and the decision ---------------------------------
1319 . '.note{display:flex;align-items:center;gap:7px;color:#98a2b3;font-size:12px;margin:0 0 20px}'
1320 . '.note svg{flex:0 0 auto}'
1321 . '.actions{display:flex;gap:12px}'
1322 . 'button{flex:1;padding:12px;border-radius:10px;border:0;font:inherit;font-size:14px;font-weight:600;cursor:pointer;'
1323 . 'transition:filter .15s,background .15s,transform .05s}button:active{transform:translateY(1px)}'
1324 . 'button:focus-visible{outline:2px solid #00b884;outline-offset:2px}'
1325 . '.approve{background:#00b884;color:#fff;box-shadow:0 6px 16px rgba(0,184,132,.28)}.approve:hover{filter:brightness(1.06)}'
1326 . '.deny{background:#fff;color:#475467;border:1px solid #d0d5dd}.deny:hover{background:#f7f8fa}'
1327 . '@media (max-width:420px){.card{padding:22px}.idt{width:104px}.link{width:44px}.idline{font-size:15px}}';
1328 }
1329
1330 /**
1331 * Register every REST route.
1332 *
1333 * Registered directly rather than through `BaseAPI` (ADR-015): the transport
1334 * routes need `__return_true` with in-handler auth, the management routes
1335 * need `manage_options`, and `BaseAPI` has one permission callback per
1336 * class.
1337 *
1338 * @since 4.9.0
1339 *
1340 * @return void
1341 */
1342 public function register_rest() {
1343 // --- Transport -------------------------------------------------------
1344 // `permission_callback` is `__return_true` because MCPServer does its
1345 // own token auth and has to answer a JSON-RPC 401 with the RFC 9728
1346 // challenge, not a bare WordPress permission failure.
1347 register_rest_route(
1348 self::NS,
1349 '/mcp',
1350 [
1351 [
1352 'methods' => 'POST',
1353 'callback' => [ $this, 'rest_mcp' ],
1354 'permission_callback' => '__return_true'
1355 ],
1356 [
1357 'methods' => 'GET',
1358 'callback' => [ $this, 'rest_mcp_get' ],
1359 'permission_callback' => '__return_true'
1360 ]
1361 ]
1362 );
1363
1364 // --- Discovery aliases (ADR-014) -------------------------------------
1365 register_rest_route(
1366 self::NS,
1367 '/mcp/oauth/protected-resource',
1368 [
1369 'methods' => 'GET',
1370 'callback' => [ $this, 'rest_protected_resource' ],
1371 'permission_callback' => '__return_true'
1372 ]
1373 );
1374 register_rest_route(
1375 self::NS,
1376 '/mcp/oauth/authorization-server',
1377 [
1378 'methods' => 'GET',
1379 'callback' => [ $this, 'rest_authorization_server' ],
1380 'permission_callback' => '__return_true'
1381 ]
1382 );
1383
1384 // --- OAuth 2.1 -------------------------------------------------------
1385 // Public by necessity: a client has to reach these *before* it holds any
1386 // credential. `/authorize` is deliberately not here — see
1387 // AUTHORIZE_QUERY_VAR.
1388 register_rest_route(
1389 self::NS,
1390 '/mcp/oauth/register',
1391 [
1392 'methods' => 'POST',
1393 'callback' => [ $this, 'rest_oauth_register' ],
1394 'permission_callback' => '__return_true'
1395 ]
1396 );
1397 register_rest_route(
1398 self::NS,
1399 '/mcp/oauth/token',
1400 [
1401 'methods' => 'POST',
1402 'callback' => [ $this, 'rest_oauth_token' ],
1403 'permission_callback' => '__return_true'
1404 ]
1405 );
1406
1407 // --- Management (the MCP admin page) ---------------------------------
1408 register_rest_route(
1409 self::NS,
1410 '/mcp/connection',
1411 [
1412 'methods' => 'GET',
1413 'callback' => [ $this, 'rest_connection' ],
1414 'permission_callback' => [ $this, 'admin_permission' ]
1415 ]
1416 );
1417 register_rest_route(
1418 self::NS,
1419 '/mcp/connect',
1420 [
1421 'methods' => 'POST',
1422 'callback' => [ $this, 'rest_connect' ],
1423 'permission_callback' => [ $this, 'admin_permission' ],
1424 'args' => [
1425 'read_only' => [
1426 'type' => 'boolean',
1427 'required' => false,
1428 'default' => false,
1429 'sanitize_callback' => 'rest_sanitize_boolean',
1430 'description' => __( 'Grant read-only access: no doc, term, FAQ or settings changes.', 'betterdocs' )
1431 ]
1432 ]
1433 ]
1434 );
1435 register_rest_route(
1436 self::NS,
1437 '/mcp/rotate',
1438 [
1439 'methods' => 'POST',
1440 'callback' => [ $this, 'rest_rotate' ],
1441 'permission_callback' => [ $this, 'admin_permission' ],
1442 'args' => [
1443 'read_only' => [
1444 'type' => 'boolean',
1445 'required' => false,
1446 'sanitize_callback' => 'rest_sanitize_boolean',
1447 'description' => __( 'Optionally set read-only on the new token; omit to keep the current scopes.', 'betterdocs' )
1448 ]
1449 ]
1450 ]
1451 );
1452 register_rest_route(
1453 self::NS,
1454 '/mcp/disconnect',
1455 [
1456 'methods' => 'POST',
1457 'callback' => [ $this, 'rest_disconnect' ],
1458 'permission_callback' => [ $this, 'admin_permission' ]
1459 ]
1460 );
1461 register_rest_route(
1462 self::NS,
1463 '/mcp/apps',
1464 [
1465 'methods' => 'GET',
1466 'callback' => [ $this, 'rest_apps' ],
1467 'permission_callback' => [ $this, 'admin_permission' ]
1468 ]
1469 );
1470 register_rest_route(
1471 self::NS,
1472 '/mcp/apps/revoke',
1473 [
1474 'methods' => 'POST',
1475 'callback' => [ $this, 'rest_revoke_app' ],
1476 'permission_callback' => [ $this, 'admin_permission' ],
1477 'args' => [
1478 'client_id' => [
1479 'type' => 'string',
1480 'required' => true,
1481 'sanitize_callback' => 'sanitize_text_field',
1482 'description' => __( 'The OAuth client_id to revoke.', 'betterdocs' )
1483 ]
1484 ]
1485 ]
1486 );
1487 register_rest_route(
1488 self::NS,
1489 '/mcp/self-test',
1490 [
1491 'methods' => 'POST',
1492 'callback' => [ $this, 'rest_self_test' ],
1493 'permission_callback' => [ $this, 'admin_permission' ]
1494 ]
1495 );
1496 // Not gated by `enable_mcp` (ADR-013): the first question an admin asks
1497 // is "why is this not working", and a health report that needs the
1498 // feature switched on cannot answer it.
1499 register_rest_route(
1500 self::NS,
1501 '/mcp/health',
1502 [
1503 'methods' => 'GET',
1504 'callback' => [ $this, 'rest_health' ],
1505 'permission_callback' => [ $this, 'admin_permission' ],
1506 'args' => [
1507 'user' => [
1508 'type' => 'integer',
1509 'required' => false,
1510 'description' => __( 'Report this user\'s capabilities instead of the caller\'s.', 'betterdocs' )
1511 ]
1512 ]
1513 ]
1514 );
1515 }
1516
1517 /**
1518 * Capability gate for the management routes.
1519 *
1520 * @since 4.9.0
1521 *
1522 * @return bool
1523 */
1524 public function admin_permission() {
1525 return current_user_can( 'manage_options' );
1526 }
1527
1528 /**
1529 * POST `/mcp` — JSON-RPC over the wp-json fallback path.
1530 *
1531 * @since 4.9.0
1532 *
1533 * @param \WP_REST_Request $request Incoming request.
1534 * @return \WP_REST_Response
1535 */
1536 public function rest_mcp( $request ) {
1537 return MCPServer::handle( $request );
1538 }
1539
1540 /**
1541 * GET `/mcp` — there is nothing to read here.
1542 *
1543 * @since 4.9.0
1544 *
1545 * @return \WP_REST_Response
1546 */
1547 public function rest_mcp_get() {
1548 $response = new \WP_REST_Response(
1549 [
1550 'error' => 'method_not_allowed',
1551 'message' => __( 'The BetterDocs MCP endpoint accepts POST only.', 'betterdocs' )
1552 ],
1553 405
1554 );
1555
1556 $response->header( 'Allow', 'POST' );
1557 $response->header( 'Cache-Control', 'no-store, private' );
1558
1559 return $response;
1560 }
1561
1562 /**
1563 * GET `/mcp/oauth/protected-resource` — RFC 9728 metadata.
1564 *
1565 * @since 4.9.0
1566 *
1567 * @return \WP_REST_Response
1568 */
1569 public function rest_protected_resource() {
1570 return $this->discovery_response( 'protected-resource' );
1571 }
1572
1573 /**
1574 * GET `/mcp/oauth/authorization-server` — RFC 8414 metadata.
1575 *
1576 * @since 4.9.0
1577 *
1578 * @return \WP_REST_Response
1579 */
1580 public function rest_authorization_server() {
1581 return $this->discovery_response( 'authorization-server' );
1582 }
1583
1584 /**
1585 * A discovery document as a REST response, or a JSON 404 when MCP is off.
1586 *
1587 * @since 4.9.0
1588 *
1589 * @param string $doc `protected-resource` or `authorization-server`.
1590 * @return \WP_REST_Response
1591 */
1592 private function discovery_response( $doc ) {
1593 if ( ! self::is_enabled() ) {
1594 return new \WP_REST_Response(
1595 [
1596 'code' => 'betterdocs_mcp_disabled',
1597 'message' => __( 'MCP is disabled on this site.', 'betterdocs' ),
1598 'data' => [ 'status' => 404 ]
1599 ],
1600 404
1601 );
1602 }
1603
1604 $response = new \WP_REST_Response( self::discovery_document( $doc ), 200 );
1605 $response->header( 'Cache-Control', 'public, max-age=3600' );
1606
1607 return $response;
1608 }
1609
1610 /**
1611 * POST `/mcp/oauth/register` — RFC 7591 dynamic client registration.
1612 *
1613 * @since 4.9.0
1614 *
1615 * @param \WP_REST_Request $request JSON body with `redirect_uris`.
1616 * @return \WP_REST_Response|\WP_Error
1617 */
1618 public function rest_oauth_register( $request ) {
1619 if ( ! self::is_enabled() ) {
1620 return new \WP_Error(
1621 'betterdocs_mcp_disabled',
1622 __( 'MCP is disabled on this site.', 'betterdocs' ),
1623 [ 'status' => 403 ]
1624 );
1625 }
1626
1627 $body = $request->get_json_params();
1628
1629 if ( ! is_array( $body ) ) {
1630 $body = [];
1631 }
1632
1633 $result = MCPOAuth::register_client( $body );
1634
1635 if ( is_wp_error( $result ) ) {
1636 return $result;
1637 }
1638
1639 $response = new \WP_REST_Response( $result, 201 );
1640 $response->header( 'Cache-Control', 'no-store' );
1641
1642 return $response;
1643 }
1644
1645 /**
1646 * POST `/mcp/oauth/token` — the code and refresh grants.
1647 *
1648 * OAuth sends `application/x-www-form-urlencoded`; JSON is accepted too.
1649 * Errors follow RFC 6749 §5.2 (`error` / `error_description`), not
1650 * WordPress' REST error shape, because that is what OAuth clients parse.
1651 *
1652 * @since 4.9.0
1653 *
1654 * @param \WP_REST_Request $request Token request.
1655 * @return \WP_REST_Response
1656 */
1657 public function rest_oauth_token( $request ) {
1658 if ( ! self::is_enabled() ) {
1659 return self::token_error( 'invalid_request', __( 'MCP is disabled on this site.', 'betterdocs' ), 403 );
1660 }
1661
1662 $body = $request->get_body_params();
1663
1664 if ( empty( $body ) ) {
1665 $json = $request->get_json_params();
1666 $body = is_array( $json ) ? $json : [];
1667 }
1668
1669 $body = array_map( 'strval', $body );
1670
1671 $result = MCPOAuth::exchange_token( $body );
1672
1673 if ( is_wp_error( $result ) ) {
1674 $data = $result->get_error_data();
1675 $data = is_array( $data ) ? $data : [];
1676
1677 return self::token_error(
1678 isset( $data['error'] ) ? (string) $data['error'] : 'invalid_request',
1679 isset( $data['error_description'] ) ? (string) $data['error_description'] : $result->get_error_message(),
1680 isset( $data['status'] ) ? (int) $data['status'] : 400
1681 );
1682 }
1683
1684 $response = new \WP_REST_Response( $result, 200 );
1685 $response->header( 'Cache-Control', 'no-store' );
1686 $response->header( 'Pragma', 'no-cache' );
1687
1688 return $response;
1689 }
1690
1691 /**
1692 * An RFC 6749 §5.2 error response.
1693 *
1694 * @since 4.9.0
1695 *
1696 * @param string $error Error code.
1697 * @param string $description Human-readable description.
1698 * @param int $status HTTP status.
1699 * @return \WP_REST_Response
1700 */
1701 private static function token_error( $error, $description, $status ) {
1702 $response = new \WP_REST_Response(
1703 [
1704 'error' => (string) $error,
1705 'error_description' => (string) $description
1706 ],
1707 (int) $status
1708 );
1709
1710 $response->header( 'Cache-Control', 'no-store' );
1711
1712 return $response;
1713 }
1714
1715 /**
1716 * GET `/mcp/connection` — pairing status for the admin page.
1717 *
1718 * @since 4.9.0
1719 *
1720 * @return \WP_REST_Response
1721 */
1722 public function rest_connection() {
1723 $this->ensure_connected();
1724
1725 $status = MCPPairing::public_status();
1726
1727 $status['enable_mcp'] = self::is_enabled();
1728 $status['mcp_endpoint'] = MCPPairing::site_endpoint();
1729 $status['mcp_endpoint_rest'] = MCPPairing::site_endpoint_fallback();
1730 $status['authorize_url'] = MCPOAuth::authorize_url();
1731 $status['issuer'] = MCPOAuth::issuer();
1732 $status['discovery'] = [
1733 'protected_resource' => MCPOAuth::resource_metadata_url(),
1734 'authorization_server' => rest_url( self::NS . '/mcp/oauth/authorization-server' )
1735 ];
1736
1737 return rest_ensure_response( $status );
1738 }
1739
1740 /**
1741 * Make sure a connection token exists whenever an administrator looks at
1742 * the MCP page with MCP switched on.
1743 *
1744 * A site that has never minted one has no `config.cli`, no JSON block and
1745 * no AI prompt — and before this existed the page answered that by hiding
1746 * every client card behind a Connect button, including the two OAuth cards
1747 * that need no token at all (ADR-056). Minting is idempotent
1748 * ({@see MCPPairing::connect()} returns the existing record untouched) and
1749 * this method is only reached from `manage_options`-gated routes, so it
1750 * costs one option write on exactly one request per site.
1751 *
1752 * Read-write on purpose: a read-only pairing is a deliberate choice made
1753 * through `POST /mcp/connect` (ADR-038), not something to fall into.
1754 *
1755 * @since 4.9.0
1756 *
1757 * @return void
1758 */
1759 private function ensure_connected() {
1760 if ( self::is_enabled() && ! MCPPairing::is_connected() ) {
1761 MCPPairing::connect();
1762 }
1763 }
1764
1765 /**
1766 * Mint the pairing token when `enable_mcp` is switched on.
1767 *
1768 * Hooked to BetterDocs' own settings save, which is the path the page's
1769 * master switch writes through. Only an **off → on** transition mints: a
1770 * save that leaves the switch alone must not resurrect a pairing an
1771 * administrator deliberately disconnected.
1772 *
1773 * @since 4.9.0
1774 *
1775 * @param bool $saved Whether the option write succeeded.
1776 * @param array $settings The settings as saved.
1777 * @param array $old_settings The settings as they were.
1778 * @return void
1779 */
1780 public function mint_on_enable( $saved, $settings, $old_settings ) {
1781 $was = is_array( $old_settings ) && ! empty( $old_settings['enable_mcp'] );
1782 $now = is_array( $settings ) && ! empty( $settings['enable_mcp'] );
1783
1784 if ( ! $was && $now ) {
1785 $this->ensure_connected();
1786 }
1787 }
1788
1789 /**
1790 * POST `/mcp/connect` — mint a connection token.
1791 *
1792 * @since 4.9.0
1793 *
1794 * @param \WP_REST_Request $request Carries optional `read_only`.
1795 * @return \WP_REST_Response
1796 */
1797 public function rest_connect( $request ) {
1798 return rest_ensure_response( MCPPairing::connect( (bool) $request->get_param( 'read_only' ) ) );
1799 }
1800
1801 /**
1802 * POST `/mcp/rotate` — mint a fresh token, killing the old one.
1803 *
1804 * @since 4.9.0
1805 *
1806 * @param \WP_REST_Request $request Carries optional `read_only`.
1807 * @return \WP_REST_Response
1808 */
1809 public function rest_rotate( $request ) {
1810 $read_only = null;
1811
1812 if ( null !== $request->get_param( 'read_only' ) ) {
1813 $read_only = (bool) $request->get_param( 'read_only' );
1814 }
1815
1816 return rest_ensure_response( MCPPairing::rotate( $read_only ) );
1817 }
1818
1819 /**
1820 * POST `/mcp/disconnect` — revoke the pairing token and every OAuth grant.
1821 *
1822 * @since 4.9.0
1823 *
1824 * @return \WP_REST_Response
1825 */
1826 public function rest_disconnect() {
1827 return rest_ensure_response( MCPPairing::disconnect() );
1828 }
1829
1830 /**
1831 * GET `/mcp/apps` — the connected OAuth clients.
1832 *
1833 * @since 4.9.0
1834 *
1835 * @return \WP_REST_Response
1836 */
1837 public function rest_apps() {
1838 return rest_ensure_response( [ 'oauth_apps' => MCPOAuth::connected_apps() ] );
1839 }
1840
1841 /**
1842 * POST `/mcp/apps/revoke` — cut off one OAuth client and return the
1843 * refreshed list, so the UI updates in a single round trip.
1844 *
1845 * The pairing token has no per-client identity and is not listed here; it is
1846 * rotated from the connection card instead.
1847 *
1848 * @since 4.9.0
1849 *
1850 * @param \WP_REST_Request $request Carries `client_id`.
1851 * @return \WP_REST_Response|\WP_Error
1852 */
1853 public function rest_revoke_app( $request ) {
1854 $client_id = (string) $request->get_param( 'client_id' );
1855
1856 if ( '' === $client_id ) {
1857 return new \WP_Error(
1858 'betterdocs_missing_client_id',
1859 __( 'A client_id is required to revoke an OAuth app.', 'betterdocs' ),
1860 [ 'status' => 400 ]
1861 );
1862 }
1863
1864 MCPOAuth::revoke_client( $client_id );
1865
1866 return rest_ensure_response( [ 'oauth_apps' => MCPOAuth::connected_apps() ] );
1867 }
1868
1869 /**
1870 * POST `/mcp/self-test` — the loopback ladder.
1871 *
1872 * A `POST` rather than a `GET` because it is the one diagnostic that makes
1873 * real outbound requests; nothing should trigger it by loading a page.
1874 *
1875 * @since 4.9.0
1876 *
1877 * @return \WP_REST_Response
1878 */
1879 public function rest_self_test() {
1880 return rest_ensure_response( $this->self_test->run() );
1881 }
1882
1883 /**
1884 * GET `/mcp/health` — the side-effect-free report.
1885 *
1886 * `?user=<id>` reports another user's capability set instead of the caller's,
1887 * which is how support answers "why can this editor not create a doc?"
1888 * without logging in as them. The route is already `manage_options`, and the
1889 * report carries no secret for any user, so no further gate is needed —
1890 * but an id that is not a real user is refused rather than silently
1891 * reported as holding nothing.
1892 *
1893 * @since 4.9.0
1894 *
1895 * @param \WP_REST_Request $request The request.
1896 * @return \WP_REST_Response|\WP_Error
1897 */
1898 public function rest_health( $request ) {
1899 $user_id = null;
1900
1901 if ( is_object( $request ) && null !== $request->get_param( 'user' ) ) {
1902 $user_id = (int) $request->get_param( 'user' );
1903
1904 if ( $user_id < 1 || ! get_user_by( 'id', $user_id ) ) {
1905 return new \WP_Error(
1906 'betterdocs_mcp_unknown_user',
1907 __( 'No user with that id exists on this site.', 'betterdocs' ),
1908 [ 'status' => 404 ]
1909 );
1910 }
1911 }
1912
1913 return rest_ensure_response( $this->health->report( $user_id ) );
1914 }
1915
1916 /**
1917 * Admin notice when MCP is on but the bundled Abilities runtime is missing.
1918 *
1919 * That combination is almost always an incomplete package — a source archive,
1920 * or a zip built without `dependencies/vendor/`. Everything else still works:
1921 * OAuth discovers, tokens mint, clients connect, and `tools/list` is an empty
1922 * array served as success. Three layers each fail softly and compose into a
1923 * connector that connects and offers nothing, with no signal anywhere. Say it
1924 * where an administrator will look.
1925 *
1926 * @since 4.9.0
1927 *
1928 * @return void
1929 */
1930 public function warn_when_runtime_missing() {
1931 if ( ! self::is_enabled() || function_exists( 'wp_register_ability' ) ) {
1932 return;
1933 }
1934
1935 if ( ! current_user_can( 'manage_options' ) ) {
1936 return;
1937 }
1938
1939 printf(
1940 '<div class="notice notice-error"><p><strong>%s</strong> %s</p></div>',
1941 esc_html__( 'BetterDocs MCP: AI assistants will connect but see no tools.', 'betterdocs' ),
1942 esc_html__( 'MCP access is enabled, but the bundled Abilities runtime (dependencies/vendor/autoload_packages.php) is missing from this installation — usually a plugin package built without it. Reinstall BetterDocs from wordpress.org or an official build; until then, connected AI clients get an empty tool list.', 'betterdocs' )
1943 );
1944 }
1945
1946 /**
1947 * The live request's HTTP method.
1948 *
1949 * @since 4.9.0
1950 *
1951 * @return string
1952 */
1953 private static function request_method() {
1954 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- compared against a literal, never stored or output.
1955 return isset( $_SERVER['REQUEST_METHOD'] ) ? (string) wp_unslash( $_SERVER['REQUEST_METHOD'] ) : 'GET';
1956 }
1957
1958 /**
1959 * A header from the live PHP request.
1960 *
1961 * @since 4.9.0
1962 *
1963 * @param string $name Header name.
1964 * @return string|null
1965 */
1966 private static function server_header( $name ) {
1967 $key = 'HTTP_' . strtoupper( str_replace( '-', '_', (string) $name ) );
1968
1969 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- a credential, compared in constant time downstream; it has to arrive verbatim.
1970 return isset( $_SERVER[ $key ] ) ? wp_unslash( $_SERVER[ $key ] ) : null;
1971 }
1972
1973 /**
1974 * Emit a `WP_REST_Response` as an HTTP response and stop.
1975 *
1976 * @since 4.9.0
1977 *
1978 * @param \WP_REST_Response $response Response to emit.
1979 * @return void
1980 */
1981 private function emit_json( $response ) {
1982 $status = $response->get_status();
1983
1984 status_header( $status );
1985
1986 foreach ( $response->get_headers() as $name => $value ) {
1987 // Re-assert the status on every header: PHP special-cases
1988 // WWW-Authenticate and forces a 401 when no status is given, which
1989 // would silently mask the 429 a lockout answers with.
1990 header( $name . ': ' . $value, true, $status );
1991 }
1992
1993 $data = $response->get_data();
1994
1995 if ( null !== $data ) {
1996 header( 'Content-Type: application/json; charset=utf-8' );
1997 echo wp_json_encode( $data );
1998 }
1999
2000 exit;
2001 }
2002 }
2003