PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.0
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.0
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 3.5.1 3.5.2 All 199 releases
betterdocs / includes / Mcp / MCPManager.php

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

1,920 lines 72.2 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 * Whether a rewrite flush has already been triggered this request.
96 *
97 * @since 4.9.0
98 *
99 * @var bool
100 */
101 private static $flushed = false;
102
103 /**
104 * The side-effect-free report behind `GET /mcp/health`.
105 *
106 * @since 4.9.0
107 *
108 * @var MCPHealth
109 */
110 private $health;
111
112 /**
113 * The loopback ladder behind `POST /mcp/self-test`.
114 *
115 * @since 4.9.0
116 *
117 * @var MCPSelfTest
118 */
119 private $self_test;
120
121 /**
122 * Registers every hook. Resolved from the container in
123 * `Plugin::initialize()`, which runs on `init` at priority 0; the two
124 * diagnostics are autowired alongside it.
125 *
126 * @since 4.9.0
127 *
128 * @param MCPHealth $health Health reporter.
129 * @param MCPSelfTest $self_test Loopback self-test.
130 */
131 public function __construct( MCPHealth $health, MCPSelfTest $self_test ) {
132 $this->health = $health;
133 $this->self_test = $self_test;
134
135 add_action( 'init', [ $this, 'add_rewrite' ] );
136 add_filter( 'query_vars', [ $this, 'register_query_vars' ] );
137 add_action( 'parse_request', [ $this, 'maybe_handle_pretty_endpoint' ] );
138 add_action( 'rest_api_init', [ $this, 'register_rest' ] );
139 add_action( 'admin_notices', [ $this, 'warn_when_runtime_missing' ] );
140
141 // The page's master switch writes through BetterDocs' own settings
142 // route, so this is where "MCP was just turned on" is observable. Mint
143 // there as well as on the first status read, so the token exists before
144 // the page asks for it (ADR-056).
145 add_action( 'betterdocs::settings::saved', [ $this, 'mint_on_enable' ], 10, 3 );
146
147 // Deleting or demoting a user kills the grants they made. Attached from
148 // here rather than from `Plugin` so the
149 // MCP transport and its grant lifecycle come up together.
150 MCPGrants::init();
151 }
152
153 /**
154 * Whether the MCP integration is switched on.
155 *
156 * @since 4.9.0
157 *
158 * @return bool
159 */
160 public static function is_enabled() {
161 if ( ! function_exists( 'betterdocs' ) ) {
162 return false;
163 }
164
165 $plugin = betterdocs();
166
167 if ( ! is_object( $plugin ) || ! isset( $plugin->settings ) || ! is_object( $plugin->settings ) ) {
168 return false;
169 }
170
171 return (bool) $plugin->settings->get( 'enable_mcp', false );
172 }
173
174 /**
175 * Register the rewrite rules, and self-heal the rewrite table.
176 *
177 * @since 4.9.0
178 *
179 * @return void
180 */
181 public function add_rewrite() {
182 foreach ( self::rules() as $regex => $query ) {
183 add_rewrite_rule( $regex, $query, 'top' );
184 }
185
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
197 self::maybe_flush();
198 }
199
200 /**
201 * The rewrite rules this plugin owns, as `regex => query`.
202 *
203 * @since 4.9.0
204 *
205 * @return array
206 */
207 private static function rules() {
208 return [
209 // Token-in-URL form: one string the user pastes into a client that
210 // has no separate token field. The bare path still takes a Bearer
211 // header.
212 '^betterdocs/mcp/([a-f0-9]{64})/?$' => 'index.php?' . self::QUERY_VAR . '=1&' . self::TOKEN_QUERY_VAR . '=$matches[1]',
213 '^betterdocs/mcp/?$' => 'index.php?' . self::QUERY_VAR . '=1',
214 '^\.well-known/oauth-(protected-resource|authorization-server)/betterdocs/mcp/?$' => 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]',
215 '^betterdocs/mcp/\.well-known/oauth-(protected-resource|authorization-server)/?$' => 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]',
216 '^betterdocs/mcp/\.well-known/openid-configuration/?$' => 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=authorization-server',
217 '^betterdocs/authorize/?$' => 'index.php?' . self::AUTHORIZE_QUERY_VAR . '=1'
218 ];
219 }
220
221 /**
222 * Flush once if any rule of ours is missing from the stored table, so the
223 * endpoints work without a manual permalink re-save — and so a rule added in
224 * a later version installs itself on upgrade.
225 *
226 * @since 4.9.0
227 *
228 * @return void
229 */
230 private static function maybe_flush() {
231 if ( self::$flushed ) {
232 return;
233 }
234
235 $rules = get_option( 'rewrite_rules' );
236
237 if ( ! is_array( $rules ) ) {
238 return;
239 }
240
241 foreach ( array_keys( self::rules() ) as $regex ) {
242 if ( ! isset( $rules[ $regex ] ) ) {
243 self::$flushed = true;
244 flush_rewrite_rules( false );
245
246 return;
247 }
248 }
249 }
250
251 /**
252 * Register our query vars.
253 *
254 * @since 4.9.0
255 *
256 * @param string[] $vars Registered query vars.
257 * @return string[]
258 */
259 public function register_query_vars( $vars ) {
260 if ( ! is_array( $vars ) ) {
261 return $vars;
262 }
263
264 $vars[] = self::QUERY_VAR;
265 $vars[] = self::TOKEN_QUERY_VAR;
266 $vars[] = self::WELLKNOWN_QUERY_VAR;
267 $vars[] = self::AUTHORIZE_QUERY_VAR;
268
269 return $vars;
270 }
271
272 /**
273 * Serve the pretty paths.
274 *
275 * Runs on `parse_request`, before the main query, and short-circuits
276 * WordPress entirely.
277 *
278 * @since 4.9.0
279 *
280 * @param \WP $wp The WP request object.
281 * @return void
282 */
283 public function maybe_handle_pretty_endpoint( $wp ) {
284 $vars = isset( $wp->query_vars ) && is_array( $wp->query_vars ) ? $wp->query_vars : [];
285
286 if ( ! empty( $vars[ self::WELLKNOWN_QUERY_VAR ] ) ) {
287 $this->emit_discovery( (string) $vars[ self::WELLKNOWN_QUERY_VAR ] );
288
289 return;
290 }
291
292 if ( ! empty( $vars[ self::AUTHORIZE_QUERY_VAR ] ) ) {
293 // The master switch is checked inside, so a switched-off site
294 // answers with the same branded page as every other refusal
295 // rather than a bare status line.
296 $this->handle_authorize_page();
297
298 return;
299 }
300
301 if ( empty( $vars[ self::QUERY_VAR ] ) ) {
302 return;
303 }
304
305 // We never open an SSE stream, so there is nothing to GET here. Say so
306 // with the method the client should have used, rather than letting the
307 // JSON-RPC layer answer a parse error to an empty body.
308 if ( 'POST' !== strtoupper( (string) self::request_method() ) ) {
309 status_header( 405 );
310 header( 'Allow: POST' );
311 header( 'Content-Type: application/json; charset=utf-8' );
312 header( 'Cache-Control: no-store, private' );
313 echo wp_json_encode(
314 [
315 'error' => 'method_not_allowed',
316 'message' => 'The BetterDocs MCP endpoint accepts POST only.'
317 ]
318 );
319 exit;
320 }
321
322 $request = new \WP_REST_Request( 'POST', '/' . self::NS . '/mcp' );
323 $request->set_header( 'content-type', 'application/json' );
324
325 $auth = self::server_header( 'authorization' );
326
327 if ( null !== $auth ) {
328 $request->set_header( 'authorization', $auth );
329 }
330
331 // A token in the path is surfaced as a Bearer header, so there is one
332 // place that reads a credential. A real header, if also sent, wins.
333 $path_token = isset( $vars[ self::TOKEN_QUERY_VAR ] ) ? (string) $vars[ self::TOKEN_QUERY_VAR ] : '';
334
335 if ( '' !== $path_token && '' === (string) $request->get_header( 'authorization' ) ) {
336 $request->set_header( 'authorization', 'Bearer ' . $path_token );
337 }
338
339 $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.
340
341 $this->emit_json( MCPServer::handle( $request ) );
342 }
343
344 /**
345 * Emit a discovery document from the pretty path.
346 *
347 * @since 4.9.0
348 *
349 * @param string $doc `protected-resource` or `authorization-server`.
350 * @return void
351 */
352 private function emit_discovery( $doc ) {
353 if ( ! self::is_enabled() ) {
354 status_header( 404 );
355 exit;
356 }
357
358 status_header( 200 );
359 header( 'Content-Type: application/json; charset=utf-8' );
360 // Discovery metadata is public and stable — unlike everything else this
361 // class serves, it is safe to cache and expensive to re-fetch.
362 header( 'Cache-Control: public, max-age=3600' );
363
364 echo wp_json_encode( self::discovery_document( $doc ) );
365 exit;
366 }
367
368 /**
369 * The discovery document for a name.
370 *
371 * @since 4.9.0
372 *
373 * @param string $doc `protected-resource` or `authorization-server`.
374 * @return array
375 */
376 private static function discovery_document( $doc ) {
377 return 'authorization-server' === $doc
378 ? MCPOAuth::authorization_server_metadata()
379 : MCPOAuth::protected_resource_metadata();
380 }
381
382 /**
383 * The browser-facing OAuth consent page.
384 *
385 * Served through a rewrite rather than as a REST route, so ordinary cookie
386 * authentication works after the `wp-login.php` round trip — see
387 * {@see self::AUTHORIZE_QUERY_VAR} for why a REST route cannot.
388 *
389 * The order of the checks is deliberate. The master switch comes first, then
390 * the visitor, then the OAuth parameters: a logged-out prober therefore
391 * learns nothing about which client ids this site has registered, because it
392 * is sent to the login screen either way.
393 *
394 * `GET` renders the consent screen. `POST` is the nonce-checked submission:
395 * Approve issues a single-use authorization code and redirects to the
396 * client's registered `redirect_uri`; Deny redirects there with
397 * `error=access_denied`. Either way this method emits its own response —
398 * an HTML page or a redirect — and exits.
399 *
400 * @since 4.9.0
401 *
402 * @return void
403 */
404 public function handle_authorize_page() {
405 if ( ! self::is_enabled() ) {
406 $this->emit_oauth_error_page(
407 __( 'MCP is switched off', 'betterdocs' ),
408 __( 'This site is not accepting AI client connections right now. An administrator can switch MCP on under BetterDocs → MCP.', 'betterdocs' ),
409 404
410 );
411 }
412
413 $is_post = 'POST' === strtoupper( (string) self::request_method() );
414
415 // The parameters arrive on the query string for the consent link and in
416 // the body for the form submit. The nonce is verified below, before any
417 // POST value is acted on; the GET side is an ordinary OAuth
418 // authorization request and carries none by design.
419 // phpcs:disable WordPress.Security.NonceVerification
420 $source = $is_post ? $_POST : $_GET;
421 // phpcs:enable WordPress.Security.NonceVerification
422
423 $params = [];
424
425 foreach ( [ 'client_id', 'redirect_uri', 'response_type', 'code_challenge', 'code_challenge_method', 'scope', 'state', 'approve', 'deny', '_betterdocs_oauth_nonce' ] as $key ) {
426 $params[ $key ] = isset( $source[ $key ] ) ? sanitize_text_field( wp_unslash( $source[ $key ] ) ) : '';
427 }
428
429 if ( ! is_user_logged_in() ) {
430 $this->redirect_to_login();
431 }
432
433 // ADR-006: anyone who can edit docs may connect a client. They grant
434 // only their own powers — every ability re-checks its own capability —
435 // and the floor is the one `MCPServer` impersonates against, so a
436 // grant approved here can never be dead on arrival.
437 if ( ! current_user_can( MCPServer::IMPERSONATION_CAPABILITY ) ) {
438 $this->emit_oauth_error_page(
439 __( 'This account cannot connect an AI client', 'betterdocs' ),
440 sprintf(
441 /* translators: %s: the required WordPress capability, e.g. edit_docs. */
442 __( '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' ),
443 MCPServer::IMPERSONATION_CAPABILITY
444 ),
445 403
446 );
447 }
448
449 $req = MCPOAuth::validate_authorize_request( $params );
450
451 if ( is_wp_error( $req ) ) {
452 $data = $req->get_error_data();
453 $redirectable = is_array( $data ) && ! empty( $data['redirectable'] );
454
455 // Report the error back to the client only when the destination is
456 // one this site registered for it. An unknown client, or a
457 // redirect_uri that matches nothing, never gets a redirect — that
458 // is the open-redirect guard.
459 if ( $redirectable && '' !== $params['redirect_uri'] ) {
460 $this->redirect_error( $params['redirect_uri'], $req->get_error_code(), $req->get_error_message(), $params['state'] );
461 }
462
463 $this->emit_oauth_error_page( __( 'Could not authorize', 'betterdocs' ), $req->get_error_message(), 400 );
464 }
465
466 if ( $is_post ) {
467 if ( ! wp_verify_nonce( $params['_betterdocs_oauth_nonce'], 'betterdocs_oauth_consent' ) ) {
468 $this->emit_oauth_error_page(
469 __( 'Security check failed', 'betterdocs' ),
470 __( 'This consent form is no longer valid. Start the connection again from your AI client.', 'betterdocs' ),
471 403
472 );
473 }
474
475 if ( '' === $params['approve'] ) {
476 $this->redirect_error( $req['redirect_uri'], 'access_denied', __( 'The user denied the request.', 'betterdocs' ), $req['state'] );
477 }
478
479 $this->redirect_success( $req['redirect_uri'], MCPOAuth::issue_code( $req, get_current_user_id() ), $req['state'] );
480 }
481
482 $this->emit_consent_screen( $req );
483 }
484
485 /**
486 * The absolute URL of the authorize request being served.
487 *
488 * Used as the return address for the `wp-login.php` round trip.
489 * `home_url()` re-anchors the path on this site, so a crafted
490 * `REQUEST_URI` cannot turn the login redirect into an off-site one.
491 *
492 * @since 4.9.0
493 *
494 * @return string
495 */
496 private function current_authorize_url() {
497 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- rebuilding this site's own URL; home_url() anchors it to this host and wp_login_url() escapes it.
498 $uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '';
499
500 return home_url( $uri );
501 }
502
503 /**
504 * Send an anonymous visitor to wp-login, returning here afterwards.
505 *
506 * @since 4.9.0
507 *
508 * @return void
509 */
510 private function redirect_to_login() {
511 wp_safe_redirect( wp_login_url( $this->current_authorize_url() ) );
512 exit;
513 }
514
515 /**
516 * Redirect back to the client with the authorization code.
517 *
518 * @since 4.9.0
519 *
520 * @param string $redirect_uri The client's validated redirect URI.
521 * @param string $code The authorization code.
522 * @param string $state The client's opaque state value.
523 * @return void
524 */
525 private function redirect_success( $redirect_uri, $code, $state ) {
526 $args = [ 'code' => rawurlencode( (string) $code ) ];
527
528 if ( '' !== (string) $state ) {
529 $args['state'] = rawurlencode( (string) $state );
530 }
531
532 // Not wp_safe_redirect(): `redirect_uri` is the client's own off-site
533 // callback, and `validate_authorize_request()` has already matched it
534 // against the set this site registered for that client.
535 wp_redirect( add_query_arg( $args, $redirect_uri ) ); // phpcs:ignore WordPress.Security.SafeRedirect -- validated OAuth redirect_uri.
536 exit;
537 }
538
539 /**
540 * Redirect back to the client with an OAuth error.
541 *
542 * @since 4.9.0
543 *
544 * @param string $redirect_uri The client's validated redirect URI.
545 * @param string $error OAuth error code.
546 * @param string $description Human-readable description.
547 * @param string $state The client's opaque state value.
548 * @return void
549 */
550 private function redirect_error( $redirect_uri, $error, $description, $state ) {
551 $args = [
552 'error' => rawurlencode( (string) $error ),
553 'error_description' => rawurlencode( (string) $description )
554 ];
555
556 if ( '' !== (string) $state ) {
557 $args['state'] = rawurlencode( (string) $state );
558 }
559
560 wp_redirect( add_query_arg( $args, $redirect_uri ) ); // phpcs:ignore WordPress.Security.SafeRedirect -- validated OAuth redirect_uri.
561 exit;
562 }
563
564 /**
565 * Render the consent screen and stop.
566 *
567 * Self-contained HTML: no theme, no admin chrome, no enqueued asset. This
568 * page is shown to someone arriving from an AI client, often mid-handshake,
569 * and it has to look the same on every site whatever the theme does.
570 *
571 * @since 4.9.0
572 *
573 * @param array $req Validated authorize parameters from {@see MCPOAuth::validate_authorize_request()}.
574 * @return void
575 */
576 private function emit_consent_screen( array $req ) {
577 $scope = isset( $req['scope'] ) ? (string) $req['scope'] : 'mcp';
578 $read_only = MCPOAuth::scope_is_read_only( $scope );
579 $user = wp_get_current_user();
580 $site_host = (string) wp_parse_url( home_url(), PHP_URL_HOST );
581
582 $client = isset( $req['client_name'] ) && '' !== (string) $req['client_name']
583 ? (string) $req['client_name']
584 : __( 'An AI assistant', 'betterdocs' );
585
586 // Where the authorization code is about to be sent, and everything this
587 // page is willing to say about it. Client registration is open
588 // (RFC 7591) and `client_name` is whatever the registrant typed, so the
589 // name alone proves nothing — anyone can register "Claude" pointing at
590 // their own callback. The callback host is the one field an attacker
591 // cannot choose freely, so the mark and the warning are both read from
592 // it and never from the name (ADR-064).
593 $callback = self::callback_identity( (string) $req['redirect_uri'] );
594
595 $access_label = $read_only
596 ? __( 'Read-only', 'betterdocs' )
597 : __( 'Read & write', 'betterdocs' );
598
599 $access_desc = $read_only
600 ? __( 'It can read your documentation, but it cannot change anything on this site.', 'betterdocs' )
601 : __( 'It acts as you: anything it creates, edits or deletes is recorded under your account.', 'betterdocs' );
602
603 $display_name = '' !== $user->display_name ? $user->display_name : $user->user_login;
604 $role_label = self::current_user_role_label();
605
606 // The initial, not `get_avatar()`: a Gravatar is a third-party image
607 // request, and this page must not tell anyone that this person is
608 // approving this app right now (ADR-064).
609 $initial = strtoupper( mb_substr( $display_name, 0, 1 ) );
610
611 // Preserve every OAuth parameter, so the POST re-validates identically.
612 $hidden = '';
613
614 foreach ( [ 'client_id', 'redirect_uri', 'code_challenge', 'scope', 'state' ] as $key ) {
615 $hidden .= sprintf(
616 '<input type="hidden" name="%1$s" value="%2$s" />',
617 esc_attr( $key ),
618 esc_attr( isset( $req[ $key ] ) ? (string) $req[ $key ] : '' )
619 );
620 }
621
622 // Re-asserted as literals: nothing else can have reached this point.
623 $hidden .= '<input type="hidden" name="code_challenge_method" value="S256" />';
624 $hidden .= '<input type="hidden" name="response_type" value="code" />';
625 $hidden .= '<input type="hidden" name="_betterdocs_oauth_nonce" value="' . esc_attr( wp_create_nonce( 'betterdocs_oauth_consent' ) ) . '" />';
626
627 $this->page_headers( 200 );
628
629 echo '<!doctype html><html lang="' . esc_attr( str_replace( '_', '-', get_locale() ) ) . '"><head><meta charset="utf-8">';
630 echo '<meta name="viewport" content="width=device-width,initial-scale=1"><meta name="referrer" content="no-referrer">';
631 echo '<title>' . esc_html__( 'Connect an AI client to BetterDocs', 'betterdocs' ) . '</title>';
632 echo '<style>' . self::page_styles() . '</style></head><body><main class="card consent">'; // phpcs:ignore WordPress.Security.EscapeOutput -- static stylesheet.
633
634 // --- Identity ---------------------------------------------------------
635 // Two tiles, the app and this site, joined by an arrow: who is asking,
636 // and what they are asking about. Every other fact on the page hangs off
637 // this one.
638 echo '<div class="lockup">';
639
640 // Every client tile is white with a hairline border and the glyph in its
641 // own colour: the two vendor marks carry their own fill from the file,
642 // and our own glyphs take the tint as their stroke. One treatment for
643 // the whole row, so a mark we drew never looks more or less endorsed
644 // than a mark a vendor drew (ADR-066, replacing ADR-065's split).
645 $tile_class = 'tile plain';
646 $tile_style = 'color:' . $callback['tint'];
647
648 echo '<div class="idt"><span class="' . esc_attr( $tile_class ) . '" style="' . esc_attr( $tile_style ) . '">'
649 . self::client_mark( $callback['mark'] ) // phpcs:ignore WordPress.Security.EscapeOutput -- static icon markup chosen by key.
650 . '</span><span class="idn"><b>' . esc_html( $client ) . '</b>';
651
652 if ( $callback['trusted'] ) {
653 echo '<span class="host">' . esc_html( $callback['host'] ) . '</span>';
654 } else {
655 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.
656 }
657
658 echo '</span></div>';
659
660 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.
661
662 echo '<div class="idt"><span class="tile bd">' . self::brand_mark( 30 ) . '</span>'; // phpcs:ignore WordPress.Security.EscapeOutput -- static icon markup.
663 echo '<span class="idn"><b>BetterDocs</b><span class="host">' . esc_html( $site_host ) . '</span></span></div>';
664
665 echo '</div>';
666
667 echo '<p class="idline">' . sprintf(
668 /* translators: 1: the AI client's name, escaped and wrapped in <strong>. 2: this site's host, escaped and wrapped in <strong>. */
669 esc_html__( '%1$s wants to work with the documentation on %2$s.', 'betterdocs' ),
670 '<strong>' . esc_html( $client ) . '</strong>',
671 '<strong>' . esc_html( $site_host ) . '</strong>'
672 ) . '</p>'; // phpcs:ignore WordPress.Security.EscapeOutput -- translated literal; both substitutions are esc_html'd above.
673
674 // --- Access -----------------------------------------------------------
675 echo '<div class="acc"><div class="top">';
676 echo '<span class="badge' . ( $read_only ? ' ro' : '' ) . '">' . esc_html( $access_label ) . '</span>';
677 echo '<p>' . esc_html( $access_desc ) . '</p>';
678 echo '</div></div>';
679
680 // --- Who is approving --------------------------------------------------
681 if ( '' !== $role_label ) {
682 $who = sprintf(
683 /* translators: 1: the login of the person approving, escaped and wrapped in <strong>. 2: their role. */
684 esc_html__( 'Signed in as %1$s &middot; %2$s', 'betterdocs' ),
685 '<strong>' . esc_html( $display_name ) . '</strong>',
686 esc_html( $role_label )
687 );
688 } else {
689 $who = sprintf(
690 /* translators: %s: the login of the person approving, escaped and wrapped in <strong>. */
691 esc_html__( 'Signed in as %s', 'betterdocs' ),
692 '<strong>' . esc_html( $display_name ) . '</strong>'
693 );
694 }
695
696 echo '<p class="who"><span class="avatar" aria-hidden="true">' . esc_html( $initial ) . '</span><span>'
697 . $who // phpcs:ignore WordPress.Security.EscapeOutput -- translated literal; every substitution was esc_html'd where it was built.
698 . '</span></p>';
699
700 // --- What it will be able to do ----------------------------------------
701 // A native <details>: the seven lines are one click away and still on the
702 // page, and the disclosure works with scripts off, which this page must.
703 $can = self::consent_capability_lines( $read_only );
704
705 if ( ! empty( $can ) ) {
706 echo '<details class="disc"><summary>' . sprintf(
707 /* translators: 1: the AI client's name. 2: how many things it will be able to do. */
708 esc_html__( 'What %1$s will be able to do (%2$d)', 'betterdocs' ),
709 esc_html( $client ),
710 count( $can )
711 ) . 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.
712
713 foreach ( $can as $line ) {
714 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.
715 }
716
717 echo '</ul></details>';
718 }
719
720 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.
721
722 echo '<form method="post" action="' . esc_url( MCPOAuth::authorize_url() ) . '">';
723 echo $hidden; // phpcs:ignore WordPress.Security.EscapeOutput -- every value escaped with esc_attr() where it was built.
724 echo '<div class="actions">';
725 echo '<button type="submit" class="deny" name="deny" value="1">' . esc_html__( 'Deny', 'betterdocs' ) . '</button>';
726 echo '<button type="submit" class="approve" name="approve" value="1">' . esc_html__( 'Approve', 'betterdocs' ) . '</button>';
727 echo '</div></form></main></body></html>';
728
729 exit;
730 }
731
732 /**
733 * Render a standalone OAuth error page and stop.
734 *
735 * Never carries a code, a token or any other secret: this page is reachable
736 * by anyone who can guess the URL.
737 *
738 * @since 4.9.0
739 *
740 * @param string $title Short headline.
741 * @param string $message What went wrong, in plain language.
742 * @param int $status HTTP status code.
743 * @return void
744 */
745 private function emit_oauth_error_page( $title, $message, $status = 400 ) {
746 $this->page_headers( $status );
747
748 echo '<!doctype html><html lang="' . esc_attr( str_replace( '_', '-', get_locale() ) ) . '"><head><meta charset="utf-8">';
749 echo '<meta name="viewport" content="width=device-width,initial-scale=1"><meta name="referrer" content="no-referrer">';
750 echo '<title>' . esc_html__( 'Authorization error', 'betterdocs' ) . '</title>';
751 echo '<style>' . self::page_styles() . '</style></head><body><main class="card center">'; // phpcs:ignore WordPress.Security.EscapeOutput -- static stylesheet.
752 echo '<div class="brand"><span class="logo">' . self::brand_mark() . '</span><b>BetterDocs</b></div>'; // phpcs:ignore WordPress.Security.EscapeOutput -- static markup.
753 echo '<h1>' . esc_html( (string) $title ) . '</h1>';
754 echo '<p class="sub">' . esc_html( (string) $message ) . '</p>';
755 echo '</main></body></html>';
756
757 exit;
758 }
759
760 /**
761 * Status and security headers shared by both browser pages.
762 *
763 * `X-Frame-Options` and `Referrer-Policy` are the two that matter here: the
764 * consent screen grants an access token on one click, so it must never be
765 * framed, and the authorization code lands in a URL the browser must not
766 * leak onward in a `Referer`.
767 *
768 * @since 4.9.0
769 *
770 * @param int $status HTTP status code.
771 * @return void
772 */
773 private function page_headers( $status ) {
774 status_header( (int) $status );
775 header( 'Content-Type: text/html; charset=utf-8' );
776 header( 'Cache-Control: no-store' );
777 header( 'X-Frame-Options: DENY' );
778 header( 'Referrer-Policy: no-referrer' );
779 }
780
781 /**
782 * What this grant lets the app do, in the approving user's own terms.
783 *
784 * Derived from `current_user_can()` over the capabilities the abilities
785 * actually gate on, because an OAuth grant carries exactly the powers of the
786 * person approving it and nothing more (ADR-006).
787 *
788 * @since 4.9.0
789 *
790 * @param bool $read_only Whether the requested scope is read-only.
791 * @return string[] Human-readable lines; may be empty.
792 */
793 private static function consent_capability_lines( $read_only ) {
794 // [ capability, read-only phrasing, read-write phrasing ]. The
795 // capability is held in a variable on purpose: these are BetterDocs'
796 // own capabilities, not core's.
797 $map = [
798 [ 'edit_docs', __( 'Read docs, categories, tags and knowledge bases', 'betterdocs' ), __( 'Create and edit docs', 'betterdocs' ) ],
799 [ 'delete_docs', '', __( 'Trash and delete docs', 'betterdocs' ) ],
800 [ 'manage_doc_terms', '', __( 'Create and manage doc categories and tags', 'betterdocs' ) ],
801 [ 'manage_knowledge_base_terms', '', __( 'Create and manage knowledge bases (BetterDocs Pro)', 'betterdocs' ) ],
802 [ 'edit_others_docs', __( 'Read FAQs and FAQ groups', 'betterdocs' ), __( 'Create and manage FAQs and FAQ groups', 'betterdocs' ) ],
803 [ 'edit_docs_settings', __( 'Read BetterDocs settings (API keys stay hidden)', 'betterdocs' ), __( 'Read and change BetterDocs settings', 'betterdocs' ) ],
804 [ 'read_docs_analytics', __( 'Read documentation analytics', 'betterdocs' ), __( 'Read documentation analytics', 'betterdocs' ) ]
805 ];
806
807 $lines = [];
808
809 foreach ( $map as $entry ) {
810 list( $capability, $read_label, $write_label ) = $entry;
811
812 $label = $read_only ? $read_label : $write_label;
813
814 if ( '' === $label || ! current_user_can( $capability ) ) {
815 continue;
816 }
817
818 $lines[] = $label;
819 }
820
821 return array_values( array_unique( $lines ) );
822 }
823
824 /**
825 * The approving user's role, as a translated label.
826 *
827 * @since 4.9.0
828 *
829 * @return string Empty when the user somehow holds no role.
830 */
831 private static function current_user_role_label() {
832 $user = wp_get_current_user();
833
834 if ( ! isset( $user->roles ) || ! is_array( $user->roles ) || empty( $user->roles ) ) {
835 return '';
836 }
837
838 $slug = (string) reset( $user->roles );
839 $roles = wp_roles();
840 $names = is_object( $roles ) && method_exists( $roles, 'get_names' ) ? $roles->get_names() : [];
841
842 return isset( $names[ $slug ] ) ? translate_user_role( $names[ $slug ] ) : $slug;
843 }
844
845 /**
846 * The BetterDocs mark, inlined.
847 *
848 * Inline rather than an `<img>` from `assets/`: this page must render
849 * identically with no second request, on a site whose asset URLs may be
850 * behind a CDN or an offline dev host.
851 *
852 * @since 4.9.0
853 *
854 * @param int $size Edge length in pixels. 22 in the error page's header
855 * lockup, 30 in the consent screen's identity tile.
856 * @return string
857 */
858 private static function brand_mark( $size = 22 ) {
859 $size = (int) $size;
860
861 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">'
862 . '<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"/>'
863 . '<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"/>'
864 . '<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"/>'
865 . '<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"/>'
866 . '</svg>';
867 }
868
869 /**
870 * A small check mark for the capability list.
871 *
872 * @since 4.9.0
873 *
874 * @return string
875 */
876 private static function tick_mark() {
877 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>';
878 }
879
880 /**
881 * A small padlock for the footer note.
882 *
883 * @since 4.9.0
884 *
885 * @return string
886 */
887 private static function lock_mark() {
888 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>';
889 }
890
891 /**
892 * The mark for one client, by key.
893 *
894 * Two kinds of drawing live here. `claude` and `openai` are the vendors' own
895 * published logo files, used exactly as published — path, viewBox and fill
896 * unchanged — each keeping its own colour on a white tile, which is why
897 * neither uses `currentColor` (ADR-066). `cursor`, `code` and `generic` are
898 * our own glyphs on the shared 24-unit grid and take the surrounding text
899 * colour. `react-src/admin/mcp/components/Icons.js` carries the same five
900 * marks for the admin page, so a client looks the same wherever it is drawn:
901 * change one and change both.
902 *
903 * Every mark is inline and self-contained: **no remote image may ever be
904 * fetched or referenced here** — it would leak the visit and the visitor's
905 * IP to a third party at exactly the moment they are deciding whether to
906 * trust that third party.
907 *
908 * @since 4.9.0
909 *
910 * @param string $key One of `claude`, `openai`, `cursor`, `code`, `generic`.
911 * @return string
912 */
913 private static function client_mark( $key ) {
914 // The vendors' own files, kept verbatim. Do not recolour, re-draw or
915 // re-grid them: an approximation of somebody else's logo is precisely
916 // what ADR-066 exists to undo.
917 $files = [
918 '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' ],
919 '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' ]
920 ];
921
922 if ( isset( $files[ $key ] ) ) {
923 $f = $files[ $key ];
924
925 return '<svg width="30" height="30" viewBox="' . $f[0] . '" fill="' . $f[1] . '" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">'
926 . '<path d="' . $f[2] . '"/></svg>';
927 }
928
929 // Cursor's arrow is a closed shape, drawn filled rather than as a
930 // hairline outline — the same drawing the vendor's own icon uses, and
931 // the shape reads at 28px where a 1.8-unit stroke would not.
932 if ( 'cursor' === $key ) {
933 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">'
934 . '<path d="M4 3l16 7-6.6 2.4L11 20 4 3z"/></svg>';
935 }
936
937 // [ stroke width, path data ] on the shared 24x24 grid.
938 $strokes = [
939 'code' => [ '1.8', '<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>' ],
940 'generic' => [ '1.8', '<path d="M12 3v18M3 12h18M6.3 6.3l11.4 11.4M17.7 6.3L6.3 17.7"/>' ]
941 ];
942
943 $icon = isset( $strokes[ $key ] ) ? $strokes[ $key ] : $strokes['generic'];
944
945 return '<svg width="28" height="28" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" stroke="currentColor" stroke-width="'
946 . $icon[0] . '" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">' . $icon[1] . '</svg>';
947 }
948
949 /**
950 * The warning triangle shown beside an untrusted callback host.
951 *
952 * @since 4.9.0
953 *
954 * @return string
955 */
956 private static function warn_mark() {
957 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">'
958 . '<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>';
959 }
960
961 /**
962 * The arrow joining the two tiles of the identity lockup.
963 *
964 * @since 4.9.0
965 *
966 * @return string
967 */
968 private static function arrow_mark() {
969 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">'
970 . '<path d="M4.5 12h14"/><path d="M13 6.5 18.5 12 13 17.5"/></svg>';
971 }
972
973 /**
974 * The chevron on the capability disclosure's summary.
975 *
976 * @since 4.9.0
977 *
978 * @return string
979 */
980 private static function chevron_mark() {
981 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">'
982 . '<polyline points="6 9.5 12 15.5 18 9.5"/></svg>';
983 }
984
985 /**
986 * Everything the consent screen may say about a callback URL.
987 *
988 * Registration (RFC 7591, {@see MCPOAuth::register_client()}) stores three
989 * fields — `name`, `redirect_uris`, `created`. `logo_uri` is deliberately
990 * not among them and must not become one: it is attacker-supplied, so a
991 * hostile client could register itself with Anthropic's logo and wear it on
992 * this screen, and fetching it would tell a third party the exact moment
993 * this person sat down to approve an app, from their IP (ADR-064).
994 *
995 * That leaves the callback host as the only identity a client cannot choose
996 * freely — it is where the authorization code is about to be sent, so a
997 * client that lies about it gets nothing. Both the mark and the warning are
998 * read from it.
999 *
1000 * @since 4.9.0
1001 *
1002 * @param string $redirect_uri The validated `redirect_uri` from the request.
1003 * @return array {
1004 * @type string $host What to print: host, plus `:port` when there is one.
1005 * @type string $mark Mark key for {@see self::client_mark()}.
1006 * @type string $tint Brand colour for the tile, `#rrggbb`.
1007 * @type bool $solid Whether the mark is a vendor's own logo file
1008 * rather than one of our glyphs. Retained for the
1009 * tested contract; this screen gives every tile
1010 * the same treatment now (ADR-066).
1011 * @type bool $trusted Whether the host may be stated quietly.
1012 * }
1013 */
1014 private static function callback_identity( $redirect_uri ) {
1015 $redirect_uri = (string) $redirect_uri;
1016 $scheme = strtolower( (string) wp_parse_url( $redirect_uri, PHP_URL_SCHEME ) );
1017 $host = strtolower( (string) wp_parse_url( $redirect_uri, PHP_URL_HOST ) );
1018 $port = wp_parse_url( $redirect_uri, PHP_URL_PORT );
1019
1020 // Native clients may register a custom scheme with no host at all
1021 // (`myapp:/cb`). Nothing is known about those, so they get the generic
1022 // mark and the warning, and the whole URI is what we can honestly print.
1023 $display = '' === $host ? $redirect_uri : $host;
1024
1025 if ( '' !== $host && $port ) {
1026 // The port is part of the identity: two locally-running apps differ
1027 // by nothing else.
1028 $display .= ':' . (int) $port;
1029 }
1030
1031 $mark = self::client_mark_for_host( $host );
1032 $loopback = 'code' === $mark['mark'];
1033
1034 // Untrusted is the default, and three separate things earn it: a host
1035 // this site does not recognise, a raw IP literal off the loopback, and
1036 // cleartext `http://` anywhere but the loopback. A recognised host over
1037 // https is trusted; so is the editor/CLI loopback flow, which is http
1038 // by nature and never leaves the machine.
1039 $trusted = true;
1040
1041 if ( ! $mark['known'] ) {
1042 $trusted = false;
1043 } elseif ( ! $loopback && self::is_ip_literal( $host ) ) {
1044 $trusted = false;
1045 } elseif ( ! $loopback && 'https' !== $scheme ) {
1046 $trusted = false;
1047 }
1048
1049 return [
1050 'host' => $display,
1051 'mark' => $mark['mark'],
1052 'tint' => $mark['tint'],
1053 'solid' => $mark['solid'],
1054 'trusted' => $trusted
1055 ];
1056 }
1057
1058 /**
1059 * Pick a client mark from a callback host.
1060 *
1061 * Matched **exactly or on a dot boundary**, never as a bare substring:
1062 * `claude.ai` and `foo.claude.ai` are Claude, while
1063 * `claude.ai.attacker.example`, `notclaude.ai` and `claude.ai.` are not and
1064 * fall through to the generic mark. A screen that gets this wrong tells the
1065 * person about to click Approve a lie about who they are talking to.
1066 *
1067 * @since 4.9.0
1068 *
1069 * @param string $host Bare callback host, lower-cased, no port.
1070 * @return array {
1071 * @type string $mark One of `claude`, `openai`, `cursor`, `code`, `generic`.
1072 * @type string $tint Brand colour, `#rrggbb`.
1073 * @type bool $solid Whether the mark is a vendor's own logo file.
1074 * @type bool $known Whether the host was recognised at all.
1075 * }
1076 */
1077 private static function client_mark_for_host( $host ) {
1078 $host = strtolower( (string) $host );
1079
1080 // Host => [ mark, tint, solid ]. Nothing here is keyed on the client's
1081 // *name* on purpose (ADR-064). `solid` marks the hosts whose glyph is a
1082 // vendor's own logo file rather than one of ours; every tile on this
1083 // screen is white either way now (ADR-066), so nothing here reads it —
1084 // it stays because `callback_identity()`'s shape is pinned by tests.
1085 $map = [
1086 'claude.ai' => [ 'claude', '#d97757', true ],
1087 'chatgpt.com' => [ 'openai', '#000000', true ],
1088 'openai.com' => [ 'openai', '#000000', true ],
1089 'cursor.sh' => [ 'cursor', '#0f172a', true ],
1090 'localhost' => [ 'code', '#0098ff', false ],
1091 '127.0.0.1' => [ 'code', '#0098ff', false ],
1092 '[::1]' => [ 'code', '#0098ff', false ]
1093 ];
1094
1095 foreach ( $map as $known => $triple ) {
1096 if ( self::host_matches( $host, $known ) ) {
1097 return [
1098 'mark' => $triple[0],
1099 'tint' => $triple[1],
1100 'solid' => $triple[2],
1101 'known' => true
1102 ];
1103 }
1104 }
1105
1106 return [
1107 'mark' => 'generic',
1108 'tint' => '#00b884',
1109 'solid' => false,
1110 'known' => false
1111 ];
1112 }
1113
1114 /**
1115 * Whether `$host` is `$known` itself or a subdomain of it.
1116 *
1117 * The whole point is the dot: a plain `strpos()` or a `str_ends_with()`
1118 * without it would hand `notclaude.ai` Claude's mark.
1119 *
1120 * @since 4.9.0
1121 *
1122 * @param string $host Candidate host, already lower-cased.
1123 * @param string $known Known host, lower-case.
1124 * @return bool
1125 */
1126 private static function host_matches( $host, $known ) {
1127 $host = (string) $host;
1128 $known = (string) $known;
1129
1130 if ( '' === $host || '' === $known ) {
1131 return false;
1132 }
1133
1134 if ( $host === $known ) {
1135 return true;
1136 }
1137
1138 // An IP literal has no subdomains. `evil.127.0.0.1` is a name somebody
1139 // else can own; it is not this machine, and it must not inherit the
1140 // loopback's trust.
1141 if ( self::is_ip_literal( $known ) ) {
1142 return false;
1143 }
1144
1145 return strlen( $host ) > strlen( $known )
1146 && substr( $host, - ( strlen( $known ) + 1 ) ) === '.' . $known;
1147 }
1148
1149 /**
1150 * Whether a host is a bare IP address rather than a name.
1151 *
1152 * `wp_parse_url()` hands back IPv6 hosts still wrapped in their brackets,
1153 * which `FILTER_VALIDATE_IP` will not take.
1154 *
1155 * @since 4.9.0
1156 *
1157 * @param string $host Bare host, no port.
1158 * @return bool
1159 */
1160 private static function is_ip_literal( $host ) {
1161 $host = trim( (string) $host, '[]' );
1162
1163 return '' !== $host && false !== filter_var( $host, FILTER_VALIDATE_IP );
1164 }
1165
1166 /**
1167 * The inline stylesheet shared by the consent and error pages.
1168 *
1169 * BetterDocs' own tokens (`docs/design-system.md`): brand green `#00b884`,
1170 * cards at radius 12–16px, `--text-color-*` neutrals.
1171 *
1172 * @since 4.9.0
1173 *
1174 * @return string
1175 */
1176 private static function page_styles() {
1177 return '*{box-sizing:border-box}'
1178 . 'body{font:15px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;'
1179 . 'background:#f7f8fa radial-gradient(900px 460px at 50% -12%,#ecfdf3,rgba(247,248,250,0)) no-repeat;'
1180 . 'color:#101828;margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}'
1181 . '.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)}'
1182 . '.card.center{text-align:center;max-width:440px}'
1183 . '.card.consent{max-width:440px}'
1184 . '.brand{display:flex;align-items:center;gap:10px;margin-bottom:20px}'
1185 . '.card.center .brand{justify-content:center}'
1186 . '.logo{width:36px;height:36px;border-radius:11px;display:inline-flex;align-items:center;justify-content:center;'
1187 . 'background:linear-gradient(135deg,#00c896,#00a877);box-shadow:0 6px 16px rgba(0,184,132,.32)}'
1188 . '.brand b{font-size:14px;font-weight:700;letter-spacing:.01em;color:#101828}'
1189 . 'h1{font-size:20px;line-height:1.3;font-weight:700;margin:0 0 6px}'
1190 . '.sub{color:#667085;font-size:13.5px;margin:0 0 20px}.sub strong{color:#101828;font-weight:600}'
1191 // -- the identity lockup: who is asking, and about what -------------
1192 . '.lockup{display:flex;align-items:flex-start;justify-content:center;margin:0 0 18px}'
1193 . '.idt{display:flex;flex-direction:column;align-items:center;gap:9px;width:116px}'
1194 . '.tile{width:56px;height:56px;border-radius:17px;display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto}'
1195 . '.tile.bd{background:linear-gradient(135deg,#00c896,#00a877);box-shadow:0 8px 20px rgba(0,184,132,.32)}'
1196 // A solid vendor tile sits beside the BetterDocs tile, which casts a
1197 // shadow; without one of its own the pair reads as two different
1198 // kinds of object.
1199 . '.tile.plain{background:#fff;border:1px solid #e4e7ec;box-shadow:0 6px 16px rgba(16,24,40,.10)}'
1200 . '.idn{display:flex;flex-direction:column;align-items:center;gap:3px}'
1201 . '.idn b{font-size:12px;font-weight:700;color:#344054}'
1202 . '.idn .host{font-size:12px;line-height:1.35;color:#667085;word-break:break-all}'
1203 . '.idn .host.warn{color:#b54708;font-weight:600;display:inline-flex;align-items:center;gap:4px;word-break:normal}'
1204 . '.idn .host.warn svg{flex:0 0 auto}'
1205 . '.link{display:flex;align-items:center;width:58px;margin-top:27px;color:#98a2b3}'
1206 . '.link i{flex:1;border-top:2px dotted #d0d5dd}'
1207 . '.link em{width:24px;height:24px;flex:0 0 auto;margin:0 4px;border-radius:50%;background:#fff;border:1px solid #e4e7ec;'
1208 . 'display:inline-flex;align-items:center;justify-content:center}'
1209 . '.idline{font-size:16px;line-height:1.45;color:#475467;text-align:center;margin:0 0 18px}'
1210 . '.idline strong{color:#101828;font-weight:700}'
1211 // -- the access block ------------------------------------------------
1212 . '.acc{border:1px solid #e4e7ec;border-radius:12px;overflow:hidden;background:#fff;margin:0 0 14px}'
1213 . '.acc .top{padding:14px 16px;background:#f7fefc}'
1214 . '.acc .top p{color:#475467;font-size:13px;margin:9px 0 0}'
1215 . '.badge{display:inline-flex;align-items:center;font-size:12px;font-weight:700;padding:3px 10px;border-radius:999px;'
1216 . 'background:#d1fadf;color:#027a48;border:1px solid #a6f4c5}'
1217 . '.badge.ro{background:#fef0c7;color:#b54708;border-color:#fdb022}'
1218 // -- who is approving ------------------------------------------------
1219 . '.who{display:flex;align-items:center;gap:9px;font-size:13px;color:#667085;margin:0 0 14px;padding:0 2px}'
1220 . '.who strong{color:#101828;font-weight:600}'
1221 . '.avatar{width:24px;height:24px;border-radius:50%;flex:0 0 auto;background:#eaecf0;color:#475467;font-size:11px;'
1222 . 'font-weight:700;display:inline-flex;align-items:center;justify-content:center}'
1223 // -- the capability disclosure (native <details>, no script) ----------
1224 . 'details.disc{border:1px solid #e4e7ec;border-radius:10px;margin:0 0 14px;background:#fff}'
1225 . 'details.disc>summary{list-style:none;cursor:pointer;padding:11px 14px;font-size:13.5px;font-weight:600;color:#344054;'
1226 . 'display:flex;align-items:center;gap:8px}'
1227 . 'details.disc>summary::-webkit-details-marker{display:none}'
1228 . 'details.disc>summary:focus-visible{outline:2px solid #00b884;outline-offset:2px;border-radius:9px}'
1229 . 'details.disc .chev{margin-left:auto;color:#98a2b3;transition:transform .15s}'
1230 . 'details.disc[open] .chev{transform:rotate(180deg)}'
1231 . 'details.disc .caps{margin:0;padding:2px 14px 14px}'
1232 . '.caps{list-style:none;margin:0 0 18px;padding:0;display:grid;gap:7px}'
1233 . '.caps li{display:flex;align-items:flex-start;gap:8px;font-size:13.5px;color:#344054}'
1234 . '.caps svg{color:#00b884;flex:0 0 auto;margin-top:3px}'
1235 // -- the footer note and the decision ---------------------------------
1236 . '.note{display:flex;align-items:center;gap:7px;color:#98a2b3;font-size:12px;margin:0 0 20px}'
1237 . '.note svg{flex:0 0 auto}'
1238 . '.actions{display:flex;gap:12px}'
1239 . 'button{flex:1;padding:12px;border-radius:10px;border:0;font:inherit;font-size:14px;font-weight:600;cursor:pointer;'
1240 . 'transition:filter .15s,background .15s,transform .05s}button:active{transform:translateY(1px)}'
1241 . 'button:focus-visible{outline:2px solid #00b884;outline-offset:2px}'
1242 . '.approve{background:#00b884;color:#fff;box-shadow:0 6px 16px rgba(0,184,132,.28)}.approve:hover{filter:brightness(1.06)}'
1243 . '.deny{background:#fff;color:#475467;border:1px solid #d0d5dd}.deny:hover{background:#f7f8fa}'
1244 . '@media (max-width:420px){.card{padding:22px}.idt{width:104px}.link{width:44px}.idline{font-size:15px}}';
1245 }
1246
1247 /**
1248 * Register every REST route.
1249 *
1250 * Registered directly rather than through `BaseAPI` (ADR-015): the transport
1251 * routes need `__return_true` with in-handler auth, the management routes
1252 * need `manage_options`, and `BaseAPI` has one permission callback per
1253 * class.
1254 *
1255 * @since 4.9.0
1256 *
1257 * @return void
1258 */
1259 public function register_rest() {
1260 // --- Transport -------------------------------------------------------
1261 // `permission_callback` is `__return_true` because MCPServer does its
1262 // own token auth and has to answer a JSON-RPC 401 with the RFC 9728
1263 // challenge, not a bare WordPress permission failure.
1264 register_rest_route(
1265 self::NS,
1266 '/mcp',
1267 [
1268 [
1269 'methods' => 'POST',
1270 'callback' => [ $this, 'rest_mcp' ],
1271 'permission_callback' => '__return_true'
1272 ],
1273 [
1274 'methods' => 'GET',
1275 'callback' => [ $this, 'rest_mcp_get' ],
1276 'permission_callback' => '__return_true'
1277 ]
1278 ]
1279 );
1280
1281 // --- Discovery aliases (ADR-014) -------------------------------------
1282 register_rest_route(
1283 self::NS,
1284 '/mcp/oauth/protected-resource',
1285 [
1286 'methods' => 'GET',
1287 'callback' => [ $this, 'rest_protected_resource' ],
1288 'permission_callback' => '__return_true'
1289 ]
1290 );
1291 register_rest_route(
1292 self::NS,
1293 '/mcp/oauth/authorization-server',
1294 [
1295 'methods' => 'GET',
1296 'callback' => [ $this, 'rest_authorization_server' ],
1297 'permission_callback' => '__return_true'
1298 ]
1299 );
1300
1301 // --- OAuth 2.1 -------------------------------------------------------
1302 // Public by necessity: a client has to reach these *before* it holds any
1303 // credential. `/authorize` is deliberately not here — see
1304 // AUTHORIZE_QUERY_VAR.
1305 register_rest_route(
1306 self::NS,
1307 '/mcp/oauth/register',
1308 [
1309 'methods' => 'POST',
1310 'callback' => [ $this, 'rest_oauth_register' ],
1311 'permission_callback' => '__return_true'
1312 ]
1313 );
1314 register_rest_route(
1315 self::NS,
1316 '/mcp/oauth/token',
1317 [
1318 'methods' => 'POST',
1319 'callback' => [ $this, 'rest_oauth_token' ],
1320 'permission_callback' => '__return_true'
1321 ]
1322 );
1323
1324 // --- Management (the MCP admin page) ---------------------------------
1325 register_rest_route(
1326 self::NS,
1327 '/mcp/connection',
1328 [
1329 'methods' => 'GET',
1330 'callback' => [ $this, 'rest_connection' ],
1331 'permission_callback' => [ $this, 'admin_permission' ]
1332 ]
1333 );
1334 register_rest_route(
1335 self::NS,
1336 '/mcp/connect',
1337 [
1338 'methods' => 'POST',
1339 'callback' => [ $this, 'rest_connect' ],
1340 'permission_callback' => [ $this, 'admin_permission' ],
1341 'args' => [
1342 'read_only' => [
1343 'type' => 'boolean',
1344 'required' => false,
1345 'default' => false,
1346 'sanitize_callback' => 'rest_sanitize_boolean',
1347 'description' => __( 'Grant read-only access: no doc, term, FAQ or settings changes.', 'betterdocs' )
1348 ]
1349 ]
1350 ]
1351 );
1352 register_rest_route(
1353 self::NS,
1354 '/mcp/rotate',
1355 [
1356 'methods' => 'POST',
1357 'callback' => [ $this, 'rest_rotate' ],
1358 'permission_callback' => [ $this, 'admin_permission' ],
1359 'args' => [
1360 'read_only' => [
1361 'type' => 'boolean',
1362 'required' => false,
1363 'sanitize_callback' => 'rest_sanitize_boolean',
1364 'description' => __( 'Optionally set read-only on the new token; omit to keep the current scopes.', 'betterdocs' )
1365 ]
1366 ]
1367 ]
1368 );
1369 register_rest_route(
1370 self::NS,
1371 '/mcp/disconnect',
1372 [
1373 'methods' => 'POST',
1374 'callback' => [ $this, 'rest_disconnect' ],
1375 'permission_callback' => [ $this, 'admin_permission' ]
1376 ]
1377 );
1378 register_rest_route(
1379 self::NS,
1380 '/mcp/apps',
1381 [
1382 'methods' => 'GET',
1383 'callback' => [ $this, 'rest_apps' ],
1384 'permission_callback' => [ $this, 'admin_permission' ]
1385 ]
1386 );
1387 register_rest_route(
1388 self::NS,
1389 '/mcp/apps/revoke',
1390 [
1391 'methods' => 'POST',
1392 'callback' => [ $this, 'rest_revoke_app' ],
1393 'permission_callback' => [ $this, 'admin_permission' ],
1394 'args' => [
1395 'client_id' => [
1396 'type' => 'string',
1397 'required' => true,
1398 'sanitize_callback' => 'sanitize_text_field',
1399 'description' => __( 'The OAuth client_id to revoke.', 'betterdocs' )
1400 ]
1401 ]
1402 ]
1403 );
1404 register_rest_route(
1405 self::NS,
1406 '/mcp/self-test',
1407 [
1408 'methods' => 'POST',
1409 'callback' => [ $this, 'rest_self_test' ],
1410 'permission_callback' => [ $this, 'admin_permission' ]
1411 ]
1412 );
1413 // Not gated by `enable_mcp` (ADR-013): the first question an admin asks
1414 // is "why is this not working", and a health report that needs the
1415 // feature switched on cannot answer it.
1416 register_rest_route(
1417 self::NS,
1418 '/mcp/health',
1419 [
1420 'methods' => 'GET',
1421 'callback' => [ $this, 'rest_health' ],
1422 'permission_callback' => [ $this, 'admin_permission' ],
1423 'args' => [
1424 'user' => [
1425 'type' => 'integer',
1426 'required' => false,
1427 'description' => __( 'Report this user\'s capabilities instead of the caller\'s.', 'betterdocs' )
1428 ]
1429 ]
1430 ]
1431 );
1432 }
1433
1434 /**
1435 * Capability gate for the management routes.
1436 *
1437 * @since 4.9.0
1438 *
1439 * @return bool
1440 */
1441 public function admin_permission() {
1442 return current_user_can( 'manage_options' );
1443 }
1444
1445 /**
1446 * POST `/mcp` — JSON-RPC over the wp-json fallback path.
1447 *
1448 * @since 4.9.0
1449 *
1450 * @param \WP_REST_Request $request Incoming request.
1451 * @return \WP_REST_Response
1452 */
1453 public function rest_mcp( $request ) {
1454 return MCPServer::handle( $request );
1455 }
1456
1457 /**
1458 * GET `/mcp` — there is nothing to read here.
1459 *
1460 * @since 4.9.0
1461 *
1462 * @return \WP_REST_Response
1463 */
1464 public function rest_mcp_get() {
1465 $response = new \WP_REST_Response(
1466 [
1467 'error' => 'method_not_allowed',
1468 'message' => __( 'The BetterDocs MCP endpoint accepts POST only.', 'betterdocs' )
1469 ],
1470 405
1471 );
1472
1473 $response->header( 'Allow', 'POST' );
1474 $response->header( 'Cache-Control', 'no-store, private' );
1475
1476 return $response;
1477 }
1478
1479 /**
1480 * GET `/mcp/oauth/protected-resource` — RFC 9728 metadata.
1481 *
1482 * @since 4.9.0
1483 *
1484 * @return \WP_REST_Response
1485 */
1486 public function rest_protected_resource() {
1487 return $this->discovery_response( 'protected-resource' );
1488 }
1489
1490 /**
1491 * GET `/mcp/oauth/authorization-server` — RFC 8414 metadata.
1492 *
1493 * @since 4.9.0
1494 *
1495 * @return \WP_REST_Response
1496 */
1497 public function rest_authorization_server() {
1498 return $this->discovery_response( 'authorization-server' );
1499 }
1500
1501 /**
1502 * A discovery document as a REST response, or a JSON 404 when MCP is off.
1503 *
1504 * @since 4.9.0
1505 *
1506 * @param string $doc `protected-resource` or `authorization-server`.
1507 * @return \WP_REST_Response
1508 */
1509 private function discovery_response( $doc ) {
1510 if ( ! self::is_enabled() ) {
1511 return new \WP_REST_Response(
1512 [
1513 'code' => 'betterdocs_mcp_disabled',
1514 'message' => __( 'MCP is disabled on this site.', 'betterdocs' ),
1515 'data' => [ 'status' => 404 ]
1516 ],
1517 404
1518 );
1519 }
1520
1521 $response = new \WP_REST_Response( self::discovery_document( $doc ), 200 );
1522 $response->header( 'Cache-Control', 'public, max-age=3600' );
1523
1524 return $response;
1525 }
1526
1527 /**
1528 * POST `/mcp/oauth/register` — RFC 7591 dynamic client registration.
1529 *
1530 * @since 4.9.0
1531 *
1532 * @param \WP_REST_Request $request JSON body with `redirect_uris`.
1533 * @return \WP_REST_Response|\WP_Error
1534 */
1535 public function rest_oauth_register( $request ) {
1536 if ( ! self::is_enabled() ) {
1537 return new \WP_Error(
1538 'betterdocs_mcp_disabled',
1539 __( 'MCP is disabled on this site.', 'betterdocs' ),
1540 [ 'status' => 403 ]
1541 );
1542 }
1543
1544 $body = $request->get_json_params();
1545
1546 if ( ! is_array( $body ) ) {
1547 $body = [];
1548 }
1549
1550 $result = MCPOAuth::register_client( $body );
1551
1552 if ( is_wp_error( $result ) ) {
1553 return $result;
1554 }
1555
1556 $response = new \WP_REST_Response( $result, 201 );
1557 $response->header( 'Cache-Control', 'no-store' );
1558
1559 return $response;
1560 }
1561
1562 /**
1563 * POST `/mcp/oauth/token` — the code and refresh grants.
1564 *
1565 * OAuth sends `application/x-www-form-urlencoded`; JSON is accepted too.
1566 * Errors follow RFC 6749 §5.2 (`error` / `error_description`), not
1567 * WordPress' REST error shape, because that is what OAuth clients parse.
1568 *
1569 * @since 4.9.0
1570 *
1571 * @param \WP_REST_Request $request Token request.
1572 * @return \WP_REST_Response
1573 */
1574 public function rest_oauth_token( $request ) {
1575 if ( ! self::is_enabled() ) {
1576 return self::token_error( 'invalid_request', __( 'MCP is disabled on this site.', 'betterdocs' ), 403 );
1577 }
1578
1579 $body = $request->get_body_params();
1580
1581 if ( empty( $body ) ) {
1582 $json = $request->get_json_params();
1583 $body = is_array( $json ) ? $json : [];
1584 }
1585
1586 $body = array_map( 'strval', $body );
1587
1588 $result = MCPOAuth::exchange_token( $body );
1589
1590 if ( is_wp_error( $result ) ) {
1591 $data = $result->get_error_data();
1592 $data = is_array( $data ) ? $data : [];
1593
1594 return self::token_error(
1595 isset( $data['error'] ) ? (string) $data['error'] : 'invalid_request',
1596 isset( $data['error_description'] ) ? (string) $data['error_description'] : $result->get_error_message(),
1597 isset( $data['status'] ) ? (int) $data['status'] : 400
1598 );
1599 }
1600
1601 $response = new \WP_REST_Response( $result, 200 );
1602 $response->header( 'Cache-Control', 'no-store' );
1603 $response->header( 'Pragma', 'no-cache' );
1604
1605 return $response;
1606 }
1607
1608 /**
1609 * An RFC 6749 §5.2 error response.
1610 *
1611 * @since 4.9.0
1612 *
1613 * @param string $error Error code.
1614 * @param string $description Human-readable description.
1615 * @param int $status HTTP status.
1616 * @return \WP_REST_Response
1617 */
1618 private static function token_error( $error, $description, $status ) {
1619 $response = new \WP_REST_Response(
1620 [
1621 'error' => (string) $error,
1622 'error_description' => (string) $description
1623 ],
1624 (int) $status
1625 );
1626
1627 $response->header( 'Cache-Control', 'no-store' );
1628
1629 return $response;
1630 }
1631
1632 /**
1633 * GET `/mcp/connection` — pairing status for the admin page.
1634 *
1635 * @since 4.9.0
1636 *
1637 * @return \WP_REST_Response
1638 */
1639 public function rest_connection() {
1640 $this->ensure_connected();
1641
1642 $status = MCPPairing::public_status();
1643
1644 $status['enable_mcp'] = self::is_enabled();
1645 $status['mcp_endpoint'] = MCPPairing::site_endpoint();
1646 $status['mcp_endpoint_rest'] = MCPPairing::site_endpoint_fallback();
1647 $status['authorize_url'] = MCPOAuth::authorize_url();
1648 $status['issuer'] = MCPOAuth::issuer();
1649 $status['discovery'] = [
1650 'protected_resource' => MCPOAuth::resource_metadata_url(),
1651 'authorization_server' => rest_url( self::NS . '/mcp/oauth/authorization-server' )
1652 ];
1653
1654 return rest_ensure_response( $status );
1655 }
1656
1657 /**
1658 * Make sure a connection token exists whenever an administrator looks at
1659 * the MCP page with MCP switched on.
1660 *
1661 * A site that has never minted one has no `config.cli`, no JSON block and
1662 * no AI prompt — and before this existed the page answered that by hiding
1663 * every client card behind a Connect button, including the two OAuth cards
1664 * that need no token at all (ADR-056). Minting is idempotent
1665 * ({@see MCPPairing::connect()} returns the existing record untouched) and
1666 * this method is only reached from `manage_options`-gated routes, so it
1667 * costs one option write on exactly one request per site.
1668 *
1669 * Read-write on purpose: a read-only pairing is a deliberate choice made
1670 * through `POST /mcp/connect` (ADR-038), not something to fall into.
1671 *
1672 * @since 4.9.0
1673 *
1674 * @return void
1675 */
1676 private function ensure_connected() {
1677 if ( self::is_enabled() && ! MCPPairing::is_connected() ) {
1678 MCPPairing::connect();
1679 }
1680 }
1681
1682 /**
1683 * Mint the pairing token when `enable_mcp` is switched on.
1684 *
1685 * Hooked to BetterDocs' own settings save, which is the path the page's
1686 * master switch writes through. Only an **off → on** transition mints: a
1687 * save that leaves the switch alone must not resurrect a pairing an
1688 * administrator deliberately disconnected.
1689 *
1690 * @since 4.9.0
1691 *
1692 * @param bool $saved Whether the option write succeeded.
1693 * @param array $settings The settings as saved.
1694 * @param array $old_settings The settings as they were.
1695 * @return void
1696 */
1697 public function mint_on_enable( $saved, $settings, $old_settings ) {
1698 $was = is_array( $old_settings ) && ! empty( $old_settings['enable_mcp'] );
1699 $now = is_array( $settings ) && ! empty( $settings['enable_mcp'] );
1700
1701 if ( ! $was && $now ) {
1702 $this->ensure_connected();
1703 }
1704 }
1705
1706 /**
1707 * POST `/mcp/connect` — mint a connection token.
1708 *
1709 * @since 4.9.0
1710 *
1711 * @param \WP_REST_Request $request Carries optional `read_only`.
1712 * @return \WP_REST_Response
1713 */
1714 public function rest_connect( $request ) {
1715 return rest_ensure_response( MCPPairing::connect( (bool) $request->get_param( 'read_only' ) ) );
1716 }
1717
1718 /**
1719 * POST `/mcp/rotate` — mint a fresh token, killing the old one.
1720 *
1721 * @since 4.9.0
1722 *
1723 * @param \WP_REST_Request $request Carries optional `read_only`.
1724 * @return \WP_REST_Response
1725 */
1726 public function rest_rotate( $request ) {
1727 $read_only = null;
1728
1729 if ( null !== $request->get_param( 'read_only' ) ) {
1730 $read_only = (bool) $request->get_param( 'read_only' );
1731 }
1732
1733 return rest_ensure_response( MCPPairing::rotate( $read_only ) );
1734 }
1735
1736 /**
1737 * POST `/mcp/disconnect` — revoke the pairing token and every OAuth grant.
1738 *
1739 * @since 4.9.0
1740 *
1741 * @return \WP_REST_Response
1742 */
1743 public function rest_disconnect() {
1744 return rest_ensure_response( MCPPairing::disconnect() );
1745 }
1746
1747 /**
1748 * GET `/mcp/apps` — the connected OAuth clients.
1749 *
1750 * @since 4.9.0
1751 *
1752 * @return \WP_REST_Response
1753 */
1754 public function rest_apps() {
1755 return rest_ensure_response( [ 'oauth_apps' => MCPOAuth::connected_apps() ] );
1756 }
1757
1758 /**
1759 * POST `/mcp/apps/revoke` — cut off one OAuth client and return the
1760 * refreshed list, so the UI updates in a single round trip.
1761 *
1762 * The pairing token has no per-client identity and is not listed here; it is
1763 * rotated from the connection card instead.
1764 *
1765 * @since 4.9.0
1766 *
1767 * @param \WP_REST_Request $request Carries `client_id`.
1768 * @return \WP_REST_Response|\WP_Error
1769 */
1770 public function rest_revoke_app( $request ) {
1771 $client_id = (string) $request->get_param( 'client_id' );
1772
1773 if ( '' === $client_id ) {
1774 return new \WP_Error(
1775 'betterdocs_missing_client_id',
1776 __( 'A client_id is required to revoke an OAuth app.', 'betterdocs' ),
1777 [ 'status' => 400 ]
1778 );
1779 }
1780
1781 MCPOAuth::revoke_client( $client_id );
1782
1783 return rest_ensure_response( [ 'oauth_apps' => MCPOAuth::connected_apps() ] );
1784 }
1785
1786 /**
1787 * POST `/mcp/self-test` — the loopback ladder.
1788 *
1789 * A `POST` rather than a `GET` because it is the one diagnostic that makes
1790 * real outbound requests; nothing should trigger it by loading a page.
1791 *
1792 * @since 4.9.0
1793 *
1794 * @return \WP_REST_Response
1795 */
1796 public function rest_self_test() {
1797 return rest_ensure_response( $this->self_test->run() );
1798 }
1799
1800 /**
1801 * GET `/mcp/health` — the side-effect-free report.
1802 *
1803 * `?user=<id>` reports another user's capability set instead of the caller's,
1804 * which is how support answers "why can this editor not create a doc?"
1805 * without logging in as them. The route is already `manage_options`, and the
1806 * report carries no secret for any user, so no further gate is needed —
1807 * but an id that is not a real user is refused rather than silently
1808 * reported as holding nothing.
1809 *
1810 * @since 4.9.0
1811 *
1812 * @param \WP_REST_Request $request The request.
1813 * @return \WP_REST_Response|\WP_Error
1814 */
1815 public function rest_health( $request ) {
1816 $user_id = null;
1817
1818 if ( is_object( $request ) && null !== $request->get_param( 'user' ) ) {
1819 $user_id = (int) $request->get_param( 'user' );
1820
1821 if ( $user_id < 1 || ! get_user_by( 'id', $user_id ) ) {
1822 return new \WP_Error(
1823 'betterdocs_mcp_unknown_user',
1824 __( 'No user with that id exists on this site.', 'betterdocs' ),
1825 [ 'status' => 404 ]
1826 );
1827 }
1828 }
1829
1830 return rest_ensure_response( $this->health->report( $user_id ) );
1831 }
1832
1833 /**
1834 * Admin notice when MCP is on but the bundled Abilities runtime is missing.
1835 *
1836 * That combination is almost always an incomplete package — a source archive,
1837 * or a zip built without `dependencies/vendor/`. Everything else still works:
1838 * OAuth discovers, tokens mint, clients connect, and `tools/list` is an empty
1839 * array served as success. Three layers each fail softly and compose into a
1840 * connector that connects and offers nothing, with no signal anywhere. Say it
1841 * where an administrator will look.
1842 *
1843 * @since 4.9.0
1844 *
1845 * @return void
1846 */
1847 public function warn_when_runtime_missing() {
1848 if ( ! self::is_enabled() || function_exists( 'wp_register_ability' ) ) {
1849 return;
1850 }
1851
1852 if ( ! current_user_can( 'manage_options' ) ) {
1853 return;
1854 }
1855
1856 printf(
1857 '<div class="notice notice-error"><p><strong>%s</strong> %s</p></div>',
1858 esc_html__( 'BetterDocs MCP: AI assistants will connect but see no tools.', 'betterdocs' ),
1859 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' )
1860 );
1861 }
1862
1863 /**
1864 * The live request's HTTP method.
1865 *
1866 * @since 4.9.0
1867 *
1868 * @return string
1869 */
1870 private static function request_method() {
1871 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- compared against a literal, never stored or output.
1872 return isset( $_SERVER['REQUEST_METHOD'] ) ? (string) wp_unslash( $_SERVER['REQUEST_METHOD'] ) : 'GET';
1873 }
1874
1875 /**
1876 * A header from the live PHP request.
1877 *
1878 * @since 4.9.0
1879 *
1880 * @param string $name Header name.
1881 * @return string|null
1882 */
1883 private static function server_header( $name ) {
1884 $key = 'HTTP_' . strtoupper( str_replace( '-', '_', (string) $name ) );
1885
1886 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- a credential, compared in constant time downstream; it has to arrive verbatim.
1887 return isset( $_SERVER[ $key ] ) ? wp_unslash( $_SERVER[ $key ] ) : null;
1888 }
1889
1890 /**
1891 * Emit a `WP_REST_Response` as an HTTP response and stop.
1892 *
1893 * @since 4.9.0
1894 *
1895 * @param \WP_REST_Response $response Response to emit.
1896 * @return void
1897 */
1898 private function emit_json( $response ) {
1899 $status = $response->get_status();
1900
1901 status_header( $status );
1902
1903 foreach ( $response->get_headers() as $name => $value ) {
1904 // Re-assert the status on every header: PHP special-cases
1905 // WWW-Authenticate and forces a 401 when no status is given, which
1906 // would silently mask the 429 a lockout answers with.
1907 header( $name . ': ' . $value, true, $status );
1908 }
1909
1910 $data = $response->get_data();
1911
1912 if ( null !== $data ) {
1913 header( 'Content-Type: application/json; charset=utf-8' );
1914 echo wp_json_encode( $data );
1915 }
1916
1917 exit;
1918 }
1919 }
1920