(token in the path) * https://thissite.com/wp-json/betterdocs/v1/mcp (always-on fallback) * * This class owns the transport: the rewrite rules, `parse_request` handling for * the pretty paths, the discovery documents, and every REST route. The JSON-RPC * itself is {@see MCPServer}; the tool surface is {@see MCPTools}; auth is * {@see MCPPairing} or {@see MCPOAuth}. * * `enable_mcp` is the master switch. Off means the endpoint, the discovery * documents and the OAuth register/token routes all refuse. It never gates * ability registration, and it never gates `/mcp/health` (ADR-013) — a health * report you can only read when the thing is already working is useless. * * Discovery is served four ways on purpose (ADR-014): * * - `/.well-known/oauth-{protected-resource,authorization-server}/betterdocs/mcp` * — the RFC 9728 §3.1 / RFC 8414 §3.1 path-**insert** form, which is what a * spec-compliant client derives from our path-based issuer. * - the bare root form, as a fallback for clients that only try that. * - `/betterdocs/mcp/.well-known/…` — the older OpenID Connect **suffix** * convention; clients built on an OIDC library often try that shape first. * - REST aliases under `betterdocs/v1`, which the 401 challenge points at, so a * host that intercepts `/.well-known/` cannot break the handshake. * * The path-specific well-known rules matter for coexistence: rewrite rules are * keyed by their regex, so two plugins sharing one broad rule would silently * overwrite each other depending on registration order. * * @since 4.9.0 */ final class MCPManager { /** * REST namespace shared with the rest of the plugin. * * @since 4.9.0 */ const NS = 'betterdocs/v1'; /** * Query var flagging a pretty `/betterdocs/mcp` request. * * @since 4.9.0 */ const QUERY_VAR = 'betterdocs_mcp'; /** * Query var carrying the token when it arrives in the URL path. * * @since 4.9.0 */ const TOKEN_QUERY_VAR = 'betterdocs_mcp_token'; /** * Query var flagging a `/.well-known/` OAuth discovery request. * * @since 4.9.0 */ const WELLKNOWN_QUERY_VAR = 'betterdocs_mcp_wellknown'; /** * Query var flagging the browser-facing OAuth consent page. * * Served **outside** the REST API deliberately: a REST route only honours * cookie auth when a REST nonce comes with it, and a browser arriving from * `wp-login.php` carries the cookie and no nonce — so `is_user_logged_in()` * would be false there and the consent screen would loop back to login for * ever. A normal front-end URL sees ordinary cookie auth. * * @since 4.9.0 */ const AUTHORIZE_QUERY_VAR = 'betterdocs_mcp_authorize'; /** * Bumped whenever the rewrite rule *set* changes shape, to force a single * flush that evicts rules retired in an earlier version. Stored against * `betterdocs_mcp_rewrite_ver`; see {@see self::maybe_flush()}. * * @since 4.9.3 */ const REWRITE_VER = '2'; /** * Whether a rewrite flush has already been triggered this request. * * @since 4.9.0 * * @var bool */ private static $flushed = false; /** * The side-effect-free report behind `GET /mcp/health`. * * @since 4.9.0 * * @var MCPHealth */ private $health; /** * The loopback ladder behind `POST /mcp/self-test`. * * @since 4.9.0 * * @var MCPSelfTest */ private $self_test; /** * Registers every hook. Resolved from the container in * `Plugin::initialize()`, which runs on `init` at priority 0; the two * diagnostics are autowired alongside it. * * @since 4.9.0 * * @param MCPHealth $health Health reporter. * @param MCPSelfTest $self_test Loopback self-test. */ public function __construct( MCPHealth $health, MCPSelfTest $self_test ) { $this->health = $health; $this->self_test = $self_test; add_action( 'init', [ $this, 'add_rewrite' ] ); add_filter( 'query_vars', [ $this, 'register_query_vars' ] ); // Claim our own discovery URLs on `do_parse_request`, which runs before // the rewrite table is even consulted, so another plugin's broad // `.well-known/oauth-*` catch-all rewrite cannot answer BetterDocs' own // discovery URL with its `resource`. Scoped to `betterdocs/mcp` only — // this never intercepts anyone else's path. Priority 0 so it wins over a // rival that also hooks here late. add_filter( 'do_parse_request', [ $this, 'serve_own_discovery' ], 0 ); add_action( 'parse_request', [ $this, 'maybe_handle_pretty_endpoint' ] ); add_action( 'rest_api_init', [ $this, 'register_rest' ] ); add_action( 'admin_notices', [ $this, 'warn_when_runtime_missing' ] ); // The page's master switch writes through BetterDocs' own settings // route, so this is where "MCP was just turned on" is observable. Mint // there as well as on the first status read, so the token exists before // the page asks for it (ADR-056). add_action( 'betterdocs::settings::saved', [ $this, 'mint_on_enable' ], 10, 3 ); // Deleting or demoting a user kills the grants they made. Attached from // here rather than from `Plugin` so the // MCP transport and its grant lifecycle come up together. MCPGrants::init(); } /** * Whether the MCP integration is switched on. * * @since 4.9.0 * * @return bool */ public static function is_enabled() { if ( ! function_exists( 'betterdocs' ) ) { return false; } $plugin = betterdocs(); if ( ! is_object( $plugin ) || ! isset( $plugin->settings ) || ! is_object( $plugin->settings ) ) { return false; } return (bool) $plugin->settings->get( 'enable_mcp', false ); } /** * Register the rewrite rules, and self-heal the rewrite table. * * @since 4.9.0 * * @return void */ public function add_rewrite() { foreach ( self::rules() as $regex => $query ) { add_rewrite_rule( $regex, $query, 'top' ); } // No broad `(?:/.*)?` catch-all: that regex is identical across every // plugin built on this transport, so it becomes a single shared rewrite // key whose winner answers *every* plugin's `/.well-known/oauth-*/*` // discovery URL — returning its own `resource` for a path it does not // own, which RFC 9728 clients reject on the exact-match check. Each rule // in self::rules() is scoped to `betterdocs/mcp`, so only our own // discovery URLs route here. The bare-root form is intentionally not // served: RFC 9728 clients derive the path-suffixed URL from the MCP // endpoint, and a suffix-less URL cannot disambiguate two MCP plugins on // one site anyway. self::maybe_flush(); } /** * The rewrite rules this plugin owns, as `regex => query`. * * @since 4.9.0 * * @return array */ private static function rules() { return [ // Token-in-URL form: one string the user pastes into a client that // has no separate token field. The bare path still takes a Bearer // header. '^betterdocs/mcp/([a-f0-9]{64})/?$' => 'index.php?' . self::QUERY_VAR . '=1&' . self::TOKEN_QUERY_VAR . '=$matches[1]', '^betterdocs/mcp/?$' => 'index.php?' . self::QUERY_VAR . '=1', '^\.well-known/oauth-(protected-resource|authorization-server)/betterdocs/mcp/?$' => 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]', '^betterdocs/mcp/\.well-known/oauth-(protected-resource|authorization-server)/?$' => 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=$matches[1]', '^betterdocs/mcp/\.well-known/openid-configuration/?$' => 'index.php?' . self::WELLKNOWN_QUERY_VAR . '=authorization-server', '^betterdocs/authorize/?$' => 'index.php?' . self::AUTHORIZE_QUERY_VAR . '=1' ]; } /** * Flush once if any rule of ours is missing from the stored table, so the * endpoints work without a manual permalink re-save — and so a rule added in * a later version installs itself on upgrade. * * @since 4.9.0 * * @return void */ private static function maybe_flush() { if ( self::$flushed ) { return; } // One-time flush when the rule set changes shape between versions. The // missing-rule check below only *adds* rules; it never evicts one that // was removed — such as the retired `(?:/.*)?` catch-all, which would // otherwise linger in the stored table and keep answering other plugins' // discovery URLs until permalinks were re-saved by hand. if ( self::REWRITE_VER !== (string) get_option( 'betterdocs_mcp_rewrite_ver', '' ) ) { self::$flushed = true; flush_rewrite_rules( false ); update_option( 'betterdocs_mcp_rewrite_ver', self::REWRITE_VER, false ); return; } $rules = get_option( 'rewrite_rules' ); if ( ! is_array( $rules ) ) { return; } foreach ( array_keys( self::rules() ) as $regex ) { if ( ! isset( $rules[ $regex ] ) ) { self::$flushed = true; flush_rewrite_rules( false ); return; } } } /** * Register our query vars. * * @since 4.9.0 * * @param string[] $vars Registered query vars. * @return string[] */ public function register_query_vars( $vars ) { if ( ! is_array( $vars ) ) { return $vars; } $vars[] = self::QUERY_VAR; $vars[] = self::TOKEN_QUERY_VAR; $vars[] = self::WELLKNOWN_QUERY_VAR; $vars[] = self::AUTHORIZE_QUERY_VAR; return $vars; } /** * Serve BetterDocs' own OAuth discovery documents straight from the request * URI, before WordPress matches any rewrite rule. * * This is what makes the discovery URLs hijack-proof: the rewrite table is a * flat, order-dependent list shared by every plugin, so a plugin whose broad * `.well-known/oauth-*` catch-all happens to sit above our path-specific rule * would otherwise answer our own URL with its `resource`. Matching the URI * here — on `do_parse_request`, before rules are consulted — sidesteps that * ordering entirely. The patterns are anchored to `betterdocs/mcp`, so this * only ever claims BetterDocs' own paths and never intercepts another * plugin's discovery URL. When MCP is off it does nothing and lets the * request fall through. * * @since 4.9.3 * * @param bool $continue Whether WordPress should continue parsing the request. * @return bool The unchanged flag when this is not one of our URLs; otherwise * the response is emitted and the request exits. */ public function serve_own_discovery( $continue ) { if ( ! self::is_enabled() ) { return $continue; } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- path only, matched with a literal-anchored regex, never stored or output. $uri = isset( $_SERVER['REQUEST_URI'] ) ? (string) wp_unslash( $_SERVER['REQUEST_URI'] ) : ''; $path = (string) wp_parse_url( $uri, PHP_URL_PATH ); // RFC 9728 path-insert form and the OIDC suffix form both name a document. if ( preg_match( '#/\.well-known/oauth-(protected-resource|authorization-server)/betterdocs/mcp/?$#', $path, $m ) || preg_match( '#/betterdocs/mcp/\.well-known/oauth-(protected-resource|authorization-server)/?$#', $path, $m ) ) { $this->emit_discovery( $m[1] ); } if ( preg_match( '#/betterdocs/mcp/\.well-known/openid-configuration/?$#', $path ) ) { $this->emit_discovery( 'authorization-server' ); } return $continue; } /** * Serve the pretty paths. * * Runs on `parse_request`, before the main query, and short-circuits * WordPress entirely. * * @since 4.9.0 * * @param \WP $wp The WP request object. * @return void */ public function maybe_handle_pretty_endpoint( $wp ) { $vars = isset( $wp->query_vars ) && is_array( $wp->query_vars ) ? $wp->query_vars : []; if ( ! empty( $vars[ self::WELLKNOWN_QUERY_VAR ] ) ) { $this->emit_discovery( (string) $vars[ self::WELLKNOWN_QUERY_VAR ] ); return; } if ( ! empty( $vars[ self::AUTHORIZE_QUERY_VAR ] ) ) { // The master switch is checked inside, so a switched-off site // answers with the same branded page as every other refusal // rather than a bare status line. $this->handle_authorize_page(); return; } if ( empty( $vars[ self::QUERY_VAR ] ) ) { return; } // We never open an SSE stream, so there is nothing to GET here. Say so // with the method the client should have used, rather than letting the // JSON-RPC layer answer a parse error to an empty body. if ( 'POST' !== strtoupper( (string) self::request_method() ) ) { status_header( 405 ); header( 'Allow: POST' ); header( 'Content-Type: application/json; charset=utf-8' ); header( 'Cache-Control: no-store, private' ); echo wp_json_encode( [ 'error' => 'method_not_allowed', 'message' => 'The BetterDocs MCP endpoint accepts POST only.' ] ); exit; } $request = new \WP_REST_Request( 'POST', '/' . self::NS . '/mcp' ); $request->set_header( 'content-type', 'application/json' ); $auth = self::server_header( 'authorization' ); if ( null !== $auth ) { $request->set_header( 'authorization', $auth ); } // A token in the path is surfaced as a Bearer header, so there is one // place that reads a credential. A real header, if also sent, wins. $path_token = isset( $vars[ self::TOKEN_QUERY_VAR ] ) ? (string) $vars[ self::TOKEN_QUERY_VAR ] : ''; if ( '' !== $path_token && '' === (string) $request->get_header( 'authorization' ) ) { $request->set_header( 'authorization', 'Bearer ' . $path_token ); } $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. $this->emit_json( MCPServer::handle( $request ) ); } /** * Emit a discovery document from the pretty path. * * @since 4.9.0 * * @param string $doc `protected-resource` or `authorization-server`. * @return void */ private function emit_discovery( $doc ) { // Only answer BetterDocs' own discovery URLs. Every rule that sets the // well-known query var carries `betterdocs/mcp` in its path, so a request // that lacks it reached here through some other plugin's catch-all // rewrite — 404 rather than hand back BetterDocs metadata for a resource // we do not own (which an RFC 9728 client would reject anyway). This also // guards the window after an upgrade, before a retired catch-all is // flushed out of the stored rewrite table. // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- path only, compared with strpos, never stored or output. $uri = isset( $_SERVER['REQUEST_URI'] ) ? (string) wp_unslash( $_SERVER['REQUEST_URI'] ) : ''; $path = (string) wp_parse_url( $uri, PHP_URL_PATH ); if ( ! self::is_enabled() || false === strpos( $path, 'betterdocs/mcp' ) ) { status_header( 404 ); exit; } status_header( 200 ); header( 'Content-Type: application/json; charset=utf-8' ); // Discovery metadata is public and stable — unlike everything else this // class serves, it is safe to cache and expensive to re-fetch. header( 'Cache-Control: public, max-age=3600' ); echo wp_json_encode( self::discovery_document( $doc ) ); exit; } /** * The discovery document for a name. * * @since 4.9.0 * * @param string $doc `protected-resource` or `authorization-server`. * @return array */ private static function discovery_document( $doc ) { return 'authorization-server' === $doc ? MCPOAuth::authorization_server_metadata() : MCPOAuth::protected_resource_metadata(); } /** * The browser-facing OAuth consent page. * * Served through a rewrite rather than as a REST route, so ordinary cookie * authentication works after the `wp-login.php` round trip — see * {@see self::AUTHORIZE_QUERY_VAR} for why a REST route cannot. * * The order of the checks is deliberate. The master switch comes first, then * the visitor, then the OAuth parameters: a logged-out prober therefore * learns nothing about which client ids this site has registered, because it * is sent to the login screen either way. * * `GET` renders the consent screen. `POST` is the nonce-checked submission: * Approve issues a single-use authorization code and redirects to the * client's registered `redirect_uri`; Deny redirects there with * `error=access_denied`. Either way this method emits its own response — * an HTML page or a redirect — and exits. * * @since 4.9.0 * * @return void */ public function handle_authorize_page() { if ( ! self::is_enabled() ) { $this->emit_oauth_error_page( __( 'MCP is switched off', 'betterdocs' ), __( 'This site is not accepting AI client connections right now. An administrator can switch MCP on under BetterDocs → MCP.', 'betterdocs' ), 404 ); } $is_post = 'POST' === strtoupper( (string) self::request_method() ); // The parameters arrive on the query string for the consent link and in // the body for the form submit. The nonce is verified below, before any // POST value is acted on; the GET side is an ordinary OAuth // authorization request and carries none by design. // phpcs:disable WordPress.Security.NonceVerification $source = $is_post ? $_POST : $_GET; // phpcs:enable WordPress.Security.NonceVerification $params = []; foreach ( [ 'client_id', 'redirect_uri', 'response_type', 'code_challenge', 'code_challenge_method', 'scope', 'state', 'approve', 'deny', '_betterdocs_oauth_nonce' ] as $key ) { $params[ $key ] = isset( $source[ $key ] ) ? sanitize_text_field( wp_unslash( $source[ $key ] ) ) : ''; } if ( ! is_user_logged_in() ) { $this->redirect_to_login(); } // ADR-006: anyone who can edit docs may connect a client. They grant // only their own powers — every ability re-checks its own capability — // and the floor is the one `MCPServer` impersonates against, so a // grant approved here can never be dead on arrival. if ( ! current_user_can( MCPServer::IMPERSONATION_CAPABILITY ) ) { $this->emit_oauth_error_page( __( 'This account cannot connect an AI client', 'betterdocs' ), sprintf( /* translators: %s: the required WordPress capability, e.g. edit_docs. */ __( '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' ), MCPServer::IMPERSONATION_CAPABILITY ), 403 ); } $req = MCPOAuth::validate_authorize_request( $params ); if ( is_wp_error( $req ) ) { $data = $req->get_error_data(); $redirectable = is_array( $data ) && ! empty( $data['redirectable'] ); // Report the error back to the client only when the destination is // one this site registered for it. An unknown client, or a // redirect_uri that matches nothing, never gets a redirect — that // is the open-redirect guard. if ( $redirectable && '' !== $params['redirect_uri'] ) { $this->redirect_error( $params['redirect_uri'], $req->get_error_code(), $req->get_error_message(), $params['state'] ); } $this->emit_oauth_error_page( __( 'Could not authorize', 'betterdocs' ), $req->get_error_message(), 400 ); } if ( $is_post ) { if ( ! wp_verify_nonce( $params['_betterdocs_oauth_nonce'], 'betterdocs_oauth_consent' ) ) { $this->emit_oauth_error_page( __( 'Security check failed', 'betterdocs' ), __( 'This consent form is no longer valid. Start the connection again from your AI client.', 'betterdocs' ), 403 ); } if ( '' === $params['approve'] ) { $this->redirect_error( $req['redirect_uri'], 'access_denied', __( 'The user denied the request.', 'betterdocs' ), $req['state'] ); } $this->redirect_success( $req['redirect_uri'], MCPOAuth::issue_code( $req, get_current_user_id() ), $req['state'] ); } $this->emit_consent_screen( $req ); } /** * The absolute URL of the authorize request being served. * * Used as the return address for the `wp-login.php` round trip. * `home_url()` re-anchors the path on this site, so a crafted * `REQUEST_URI` cannot turn the login redirect into an off-site one. * * @since 4.9.0 * * @return string */ private function current_authorize_url() { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- rebuilding this site's own URL; home_url() anchors it to this host and wp_login_url() escapes it. $uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : ''; return home_url( $uri ); } /** * Send an anonymous visitor to wp-login, returning here afterwards. * * @since 4.9.0 * * @return void */ private function redirect_to_login() { wp_safe_redirect( wp_login_url( $this->current_authorize_url() ) ); exit; } /** * Redirect back to the client with the authorization code. * * @since 4.9.0 * * @param string $redirect_uri The client's validated redirect URI. * @param string $code The authorization code. * @param string $state The client's opaque state value. * @return void */ private function redirect_success( $redirect_uri, $code, $state ) { $args = [ 'code' => rawurlencode( (string) $code ) ]; if ( '' !== (string) $state ) { $args['state'] = rawurlencode( (string) $state ); } // Not wp_safe_redirect(): `redirect_uri` is the client's own off-site // callback, and `validate_authorize_request()` has already matched it // against the set this site registered for that client. wp_redirect( add_query_arg( $args, $redirect_uri ) ); // phpcs:ignore WordPress.Security.SafeRedirect -- validated OAuth redirect_uri. exit; } /** * Redirect back to the client with an OAuth error. * * @since 4.9.0 * * @param string $redirect_uri The client's validated redirect URI. * @param string $error OAuth error code. * @param string $description Human-readable description. * @param string $state The client's opaque state value. * @return void */ private function redirect_error( $redirect_uri, $error, $description, $state ) { $args = [ 'error' => rawurlencode( (string) $error ), 'error_description' => rawurlencode( (string) $description ) ]; if ( '' !== (string) $state ) { $args['state'] = rawurlencode( (string) $state ); } wp_redirect( add_query_arg( $args, $redirect_uri ) ); // phpcs:ignore WordPress.Security.SafeRedirect -- validated OAuth redirect_uri. exit; } /** * Render the consent screen and stop. * * Self-contained HTML: no theme, no admin chrome, no enqueued asset. This * page is shown to someone arriving from an AI client, often mid-handshake, * and it has to look the same on every site whatever the theme does. * * @since 4.9.0 * * @param array $req Validated authorize parameters from {@see MCPOAuth::validate_authorize_request()}. * @return void */ private function emit_consent_screen( array $req ) { $scope = isset( $req['scope'] ) ? (string) $req['scope'] : 'mcp'; $read_only = MCPOAuth::scope_is_read_only( $scope ); $user = wp_get_current_user(); $site_host = (string) wp_parse_url( home_url(), PHP_URL_HOST ); $client = isset( $req['client_name'] ) && '' !== (string) $req['client_name'] ? (string) $req['client_name'] : __( 'An AI assistant', 'betterdocs' ); // Where the authorization code is about to be sent, and everything this // page is willing to say about it. Client registration is open // (RFC 7591) and `client_name` is whatever the registrant typed, so the // name alone proves nothing — anyone can register "Claude" pointing at // their own callback. The callback host is the one field an attacker // cannot choose freely, so the mark and the warning are both read from // it and never from the name (ADR-064). $callback = self::callback_identity( (string) $req['redirect_uri'] ); $access_label = $read_only ? __( 'Read-only', 'betterdocs' ) : __( 'Read & write', 'betterdocs' ); $access_desc = $read_only ? __( 'It can read your documentation, but it cannot change anything on this site.', 'betterdocs' ) : __( 'It acts as you: anything it creates, edits or deletes is recorded under your account.', 'betterdocs' ); $display_name = '' !== $user->display_name ? $user->display_name : $user->user_login; $role_label = self::current_user_role_label(); // The initial, not `get_avatar()`: a Gravatar is a third-party image // request, and this page must not tell anyone that this person is // approving this app right now (ADR-064). $initial = strtoupper( mb_substr( $display_name, 0, 1 ) ); // Preserve every OAuth parameter, so the POST re-validates identically. $hidden = ''; foreach ( [ 'client_id', 'redirect_uri', 'code_challenge', 'scope', 'state' ] as $key ) { $hidden .= sprintf( '', esc_attr( $key ), esc_attr( isset( $req[ $key ] ) ? (string) $req[ $key ] : '' ) ); } // Re-asserted as literals: nothing else can have reached this point. $hidden .= ''; $hidden .= ''; $hidden .= ''; $this->page_headers( 200 ); echo ''; echo ''; echo '' . esc_html__( 'Connect an AI client to BetterDocs', 'betterdocs' ) . ''; echo '
'; // phpcs:ignore WordPress.Security.EscapeOutput -- static stylesheet. // --- Identity --------------------------------------------------------- // Two tiles, the app and this site, joined by an arrow: who is asking, // and what they are asking about. Every other fact on the page hangs off // this one. echo '
'; // Every client tile is white with a hairline border and the glyph in its // own colour: the two vendor marks carry their own fill from the file, // and our own glyphs take the tint as their stroke. One treatment for // the whole row, so a mark we drew never looks more or less endorsed // than a mark a vendor drew (ADR-066, replacing ADR-065's split). $tile_class = 'tile plain'; $tile_style = 'color:' . $callback['tint']; echo '
' . self::client_mark( $callback['mark'] ) // phpcs:ignore WordPress.Security.EscapeOutput -- static icon markup chosen by key. . '' . esc_html( $client ) . ''; if ( $callback['trusted'] ) { echo '' . esc_html( $callback['host'] ) . ''; } else { echo '' . self::warn_mark() . esc_html( $callback['host'] ) . ''; // phpcs:ignore WordPress.Security.EscapeOutput -- static icon markup; the host is esc_html'd. } echo '
'; echo ''; // phpcs:ignore WordPress.Security.EscapeOutput -- static icon markup. echo '
' . self::brand_mark( 30 ) . ''; // phpcs:ignore WordPress.Security.EscapeOutput -- static icon markup. echo 'BetterDocs' . esc_html( $site_host ) . '
'; echo '
'; echo '

' . sprintf( /* translators: 1: the AI client's name, escaped and wrapped in . 2: this site's host, escaped and wrapped in . */ esc_html__( '%1$s wants to work with the documentation on %2$s.', 'betterdocs' ), '' . esc_html( $client ) . '', '' . esc_html( $site_host ) . '' ) . '

'; // phpcs:ignore WordPress.Security.EscapeOutput -- translated literal; both substitutions are esc_html'd above. // --- Access ----------------------------------------------------------- echo '
'; echo '' . esc_html( $access_label ) . ''; echo '

' . esc_html( $access_desc ) . '

'; echo '
'; // --- Who is approving -------------------------------------------------- if ( '' !== $role_label ) { $who = sprintf( /* translators: 1: the login of the person approving, escaped and wrapped in . 2: their role. */ esc_html__( 'Signed in as %1$s · %2$s', 'betterdocs' ), '' . esc_html( $display_name ) . '', esc_html( $role_label ) ); } else { $who = sprintf( /* translators: %s: the login of the person approving, escaped and wrapped in . */ esc_html__( 'Signed in as %s', 'betterdocs' ), '' . esc_html( $display_name ) . '' ); } echo '

' . $who // phpcs:ignore WordPress.Security.EscapeOutput -- translated literal; every substitution was esc_html'd where it was built. . '

'; // --- What it will be able to do ---------------------------------------- // A native
: the seven lines are one click away and still on the // page, and the disclosure works with scripts off, which this page must. $can = self::consent_capability_lines( $read_only ); if ( ! empty( $can ) ) { echo '
' . sprintf( /* translators: 1: the AI client's name. 2: how many things it will be able to do. */ esc_html__( 'What %1$s will be able to do (%2$d)', 'betterdocs' ), esc_html( $client ), count( $can ) ) . self::chevron_mark() . '
    '; // phpcs:ignore WordPress.Security.EscapeOutput -- translated literal; the client name is esc_html'd and the icon is static. foreach ( $can as $line ) { echo '
  • ' . self::tick_mark() . '' . esc_html( $line ) . '
  • '; // phpcs:ignore WordPress.Security.EscapeOutput -- static icon markup; the line is esc_html'd. } echo '
'; } echo '

' . self::lock_mark() . '' . esc_html__( 'Secured with OAuth. You can revoke this app at any time under BetterDocs → MCP.', 'betterdocs' ) . '

'; // phpcs:ignore WordPress.Security.EscapeOutput -- static icon markup; the text is esc_html'd. echo '
'; echo $hidden; // phpcs:ignore WordPress.Security.EscapeOutput -- every value escaped with esc_attr() where it was built. echo '
'; echo ''; echo ''; echo '
'; exit; } /** * Render a standalone OAuth error page and stop. * * Never carries a code, a token or any other secret: this page is reachable * by anyone who can guess the URL. * * @since 4.9.0 * * @param string $title Short headline. * @param string $message What went wrong, in plain language. * @param int $status HTTP status code. * @return void */ private function emit_oauth_error_page( $title, $message, $status = 400 ) { $this->page_headers( $status ); echo ''; echo ''; echo '' . esc_html__( 'Authorization error', 'betterdocs' ) . ''; echo '
'; // phpcs:ignore WordPress.Security.EscapeOutput -- static stylesheet. echo '
BetterDocs
'; // phpcs:ignore WordPress.Security.EscapeOutput -- static markup. echo '

' . esc_html( (string) $title ) . '

'; echo '

' . esc_html( (string) $message ) . '

'; echo '
'; exit; } /** * Status and security headers shared by both browser pages. * * `X-Frame-Options` and `Referrer-Policy` are the two that matter here: the * consent screen grants an access token on one click, so it must never be * framed, and the authorization code lands in a URL the browser must not * leak onward in a `Referer`. * * @since 4.9.0 * * @param int $status HTTP status code. * @return void */ private function page_headers( $status ) { status_header( (int) $status ); header( 'Content-Type: text/html; charset=utf-8' ); header( 'Cache-Control: no-store' ); header( 'X-Frame-Options: DENY' ); header( 'Referrer-Policy: no-referrer' ); } /** * What this grant lets the app do, in the approving user's own terms. * * Derived from `current_user_can()` over the capabilities the abilities * actually gate on, because an OAuth grant carries exactly the powers of the * person approving it and nothing more (ADR-006). * * @since 4.9.0 * * @param bool $read_only Whether the requested scope is read-only. * @return string[] Human-readable lines; may be empty. */ private static function consent_capability_lines( $read_only ) { // [ capability, read-only phrasing, read-write phrasing ]. The // capability is held in a variable on purpose: these are BetterDocs' // own capabilities, not core's. $map = [ [ 'edit_docs', __( 'Read docs, categories, tags and knowledge bases', 'betterdocs' ), __( 'Create and edit docs', 'betterdocs' ) ], [ 'delete_docs', '', __( 'Trash and delete docs', 'betterdocs' ) ], [ 'manage_doc_terms', '', __( 'Create and manage doc categories and tags', 'betterdocs' ) ], [ 'manage_knowledge_base_terms', '', __( 'Create and manage knowledge bases (BetterDocs Pro)', 'betterdocs' ) ], [ 'edit_others_docs', __( 'Read FAQs and FAQ groups', 'betterdocs' ), __( 'Create and manage FAQs and FAQ groups', 'betterdocs' ) ], [ 'edit_docs_settings', __( 'Read BetterDocs settings (API keys stay hidden)', 'betterdocs' ), __( 'Read and change BetterDocs settings', 'betterdocs' ) ], [ 'read_docs_analytics', __( 'Read documentation analytics', 'betterdocs' ), __( 'Read documentation analytics', 'betterdocs' ) ] ]; $lines = []; foreach ( $map as $entry ) { list( $capability, $read_label, $write_label ) = $entry; $label = $read_only ? $read_label : $write_label; if ( '' === $label || ! current_user_can( $capability ) ) { continue; } $lines[] = $label; } return array_values( array_unique( $lines ) ); } /** * The approving user's role, as a translated label. * * @since 4.9.0 * * @return string Empty when the user somehow holds no role. */ private static function current_user_role_label() { $user = wp_get_current_user(); if ( ! isset( $user->roles ) || ! is_array( $user->roles ) || empty( $user->roles ) ) { return ''; } $slug = (string) reset( $user->roles ); $roles = wp_roles(); $names = is_object( $roles ) && method_exists( $roles, 'get_names' ) ? $roles->get_names() : []; return isset( $names[ $slug ] ) ? translate_user_role( $names[ $slug ] ) : $slug; } /** * The BetterDocs mark, inlined. * * Inline rather than an `` from `assets/`: this page must render * identically with no second request, on a site whose asset URLs may be * behind a CDN or an offline dev host. * * @since 4.9.0 * * @param int $size Edge length in pixels. 22 in the error page's header * lockup, 30 in the consent screen's identity tile. * @return string */ private static function brand_mark( $size = 22 ) { $size = (int) $size; return ''; } /** * A small check mark for the capability list. * * @since 4.9.0 * * @return string */ private static function tick_mark() { return ''; } /** * A small padlock for the footer note. * * @since 4.9.0 * * @return string */ private static function lock_mark() { return ''; } /** * The mark for one client, by key. * * Two kinds of drawing live here. `claude` and `openai` are the vendors' own * published logo files, used exactly as published — path, viewBox and fill * unchanged — each keeping its own colour on a white tile, which is why * neither uses `currentColor` (ADR-066). `cursor`, `code` and `generic` are * our own glyphs on the shared 24-unit grid and take the surrounding text * colour. `react-src/admin/mcp/components/Icons.js` carries the same five * marks for the admin page, so a client looks the same wherever it is drawn: * change one and change both. * * Every mark is inline and self-contained: **no remote image may ever be * fetched or referenced here** — it would leak the visit and the visitor's * IP to a third party at exactly the moment they are deciding whether to * trust that third party. * * @since 4.9.0 * * @param string $key One of `claude`, `openai`, `cursor`, `code`, `generic`. * @return string */ private static function client_mark( $key ) { // The vendors' own files, kept verbatim. Do not recolour, re-draw or // re-grid them: an approximation of somebody else's logo is precisely // what ADR-066 exists to undo. $files = [ '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' ], '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' ] ]; if ( isset( $files[ $key ] ) ) { $f = $files[ $key ]; return ''; } // Cursor's arrow is a closed shape, drawn filled rather than as a // hairline outline — the same drawing the vendor's own icon uses, and // the shape reads at 28px where a 1.8-unit stroke would not. if ( 'cursor' === $key ) { return ''; } // [ stroke width, path data ] on the shared 24x24 grid. $strokes = [ 'code' => [ '1.8', '' ], 'generic' => [ '1.8', '' ] ]; $icon = isset( $strokes[ $key ] ) ? $strokes[ $key ] : $strokes['generic']; return ''; } /** * The warning triangle shown beside an untrusted callback host. * * @since 4.9.0 * * @return string */ private static function warn_mark() { return ''; } /** * The arrow joining the two tiles of the identity lockup. * * @since 4.9.0 * * @return string */ private static function arrow_mark() { return ''; } /** * The chevron on the capability disclosure's summary. * * @since 4.9.0 * * @return string */ private static function chevron_mark() { return ''; } /** * Everything the consent screen may say about a callback URL. * * Registration (RFC 7591, {@see MCPOAuth::register_client()}) stores three * fields — `name`, `redirect_uris`, `created`. `logo_uri` is deliberately * not among them and must not become one: it is attacker-supplied, so a * hostile client could register itself with Anthropic's logo and wear it on * this screen, and fetching it would tell a third party the exact moment * this person sat down to approve an app, from their IP (ADR-064). * * That leaves the callback host as the only identity a client cannot choose * freely — it is where the authorization code is about to be sent, so a * client that lies about it gets nothing. Both the mark and the warning are * read from it. * * @since 4.9.0 * * @param string $redirect_uri The validated `redirect_uri` from the request. * @return array { * @type string $host What to print: host, plus `:port` when there is one. * @type string $mark Mark key for {@see self::client_mark()}. * @type string $tint Brand colour for the tile, `#rrggbb`. * @type bool $solid Whether the mark is a vendor's own logo file * rather than one of our glyphs. Retained for the * tested contract; this screen gives every tile * the same treatment now (ADR-066). * @type bool $trusted Whether the host may be stated quietly. * } */ private static function callback_identity( $redirect_uri ) { $redirect_uri = (string) $redirect_uri; $scheme = strtolower( (string) wp_parse_url( $redirect_uri, PHP_URL_SCHEME ) ); $host = strtolower( (string) wp_parse_url( $redirect_uri, PHP_URL_HOST ) ); $port = wp_parse_url( $redirect_uri, PHP_URL_PORT ); // Native clients may register a custom scheme with no host at all // (`myapp:/cb`). Nothing is known about those, so they get the generic // mark and the warning, and the whole URI is what we can honestly print. $display = '' === $host ? $redirect_uri : $host; if ( '' !== $host && $port ) { // The port is part of the identity: two locally-running apps differ // by nothing else. $display .= ':' . (int) $port; } $mark = self::client_mark_for_host( $host ); $loopback = 'code' === $mark['mark']; // Untrusted is the default, and three separate things earn it: a host // this site does not recognise, a raw IP literal off the loopback, and // cleartext `http://` anywhere but the loopback. A recognised host over // https is trusted; so is the editor/CLI loopback flow, which is http // by nature and never leaves the machine. $trusted = true; if ( ! $mark['known'] ) { $trusted = false; } elseif ( ! $loopback && self::is_ip_literal( $host ) ) { $trusted = false; } elseif ( ! $loopback && 'https' !== $scheme ) { $trusted = false; } return [ 'host' => $display, 'mark' => $mark['mark'], 'tint' => $mark['tint'], 'solid' => $mark['solid'], 'trusted' => $trusted ]; } /** * Pick a client mark from a callback host. * * Matched **exactly or on a dot boundary**, never as a bare substring: * `claude.ai` and `foo.claude.ai` are Claude, while * `claude.ai.attacker.example`, `notclaude.ai` and `claude.ai.` are not and * fall through to the generic mark. A screen that gets this wrong tells the * person about to click Approve a lie about who they are talking to. * * @since 4.9.0 * * @param string $host Bare callback host, lower-cased, no port. * @return array { * @type string $mark One of `claude`, `openai`, `cursor`, `code`, `generic`. * @type string $tint Brand colour, `#rrggbb`. * @type bool $solid Whether the mark is a vendor's own logo file. * @type bool $known Whether the host was recognised at all. * } */ private static function client_mark_for_host( $host ) { $host = strtolower( (string) $host ); // Host => [ mark, tint, solid ]. Nothing here is keyed on the client's // *name* on purpose (ADR-064). `solid` marks the hosts whose glyph is a // vendor's own logo file rather than one of ours; every tile on this // screen is white either way now (ADR-066), so nothing here reads it — // it stays because `callback_identity()`'s shape is pinned by tests. $map = [ 'claude.ai' => [ 'claude', '#d97757', true ], 'chatgpt.com' => [ 'openai', '#000000', true ], 'openai.com' => [ 'openai', '#000000', true ], 'cursor.sh' => [ 'cursor', '#0f172a', true ], 'localhost' => [ 'code', '#0098ff', false ], '127.0.0.1' => [ 'code', '#0098ff', false ], '[::1]' => [ 'code', '#0098ff', false ] ]; foreach ( $map as $known => $triple ) { if ( self::host_matches( $host, $known ) ) { return [ 'mark' => $triple[0], 'tint' => $triple[1], 'solid' => $triple[2], 'known' => true ]; } } return [ 'mark' => 'generic', 'tint' => '#00b884', 'solid' => false, 'known' => false ]; } /** * Whether `$host` is `$known` itself or a subdomain of it. * * The whole point is the dot: a plain `strpos()` or a `str_ends_with()` * without it would hand `notclaude.ai` Claude's mark. * * @since 4.9.0 * * @param string $host Candidate host, already lower-cased. * @param string $known Known host, lower-case. * @return bool */ private static function host_matches( $host, $known ) { $host = (string) $host; $known = (string) $known; if ( '' === $host || '' === $known ) { return false; } if ( $host === $known ) { return true; } // An IP literal has no subdomains. `evil.127.0.0.1` is a name somebody // else can own; it is not this machine, and it must not inherit the // loopback's trust. if ( self::is_ip_literal( $known ) ) { return false; } return strlen( $host ) > strlen( $known ) && substr( $host, - ( strlen( $known ) + 1 ) ) === '.' . $known; } /** * Whether a host is a bare IP address rather than a name. * * `wp_parse_url()` hands back IPv6 hosts still wrapped in their brackets, * which `FILTER_VALIDATE_IP` will not take. * * @since 4.9.0 * * @param string $host Bare host, no port. * @return bool */ private static function is_ip_literal( $host ) { $host = trim( (string) $host, '[]' ); return '' !== $host && false !== filter_var( $host, FILTER_VALIDATE_IP ); } /** * The inline stylesheet shared by the consent and error pages. * * BetterDocs' own tokens (`docs/design-system.md`): brand green `#00b884`, * cards at radius 12–16px, `--text-color-*` neutrals. * * @since 4.9.0 * * @return string */ private static function page_styles() { return '*{box-sizing:border-box}' . 'body{font:15px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;' . 'background:#f7f8fa radial-gradient(900px 460px at 50% -12%,#ecfdf3,rgba(247,248,250,0)) no-repeat;' . 'color:#101828;margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}' . '.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)}' . '.card.center{text-align:center;max-width:440px}' . '.card.consent{max-width:440px}' . '.brand{display:flex;align-items:center;gap:10px;margin-bottom:20px}' . '.card.center .brand{justify-content:center}' . '.logo{width:36px;height:36px;border-radius:11px;display:inline-flex;align-items:center;justify-content:center;' . 'background:linear-gradient(135deg,#00c896,#00a877);box-shadow:0 6px 16px rgba(0,184,132,.32)}' . '.brand b{font-size:14px;font-weight:700;letter-spacing:.01em;color:#101828}' . 'h1{font-size:20px;line-height:1.3;font-weight:700;margin:0 0 6px}' . '.sub{color:#667085;font-size:13.5px;margin:0 0 20px}.sub strong{color:#101828;font-weight:600}' // -- the identity lockup: who is asking, and about what ------------- . '.lockup{display:flex;align-items:flex-start;justify-content:center;margin:0 0 18px}' . '.idt{display:flex;flex-direction:column;align-items:center;gap:9px;width:116px}' . '.tile{width:56px;height:56px;border-radius:17px;display:inline-flex;align-items:center;justify-content:center;flex:0 0 auto}' . '.tile.bd{background:linear-gradient(135deg,#00c896,#00a877);box-shadow:0 8px 20px rgba(0,184,132,.32)}' // A solid vendor tile sits beside the BetterDocs tile, which casts a // shadow; without one of its own the pair reads as two different // kinds of object. . '.tile.plain{background:#fff;border:1px solid #e4e7ec;box-shadow:0 6px 16px rgba(16,24,40,.10)}' . '.idn{display:flex;flex-direction:column;align-items:center;gap:3px}' . '.idn b{font-size:12px;font-weight:700;color:#344054}' . '.idn .host{font-size:12px;line-height:1.35;color:#667085;word-break:break-all}' . '.idn .host.warn{color:#b54708;font-weight:600;display:inline-flex;align-items:center;gap:4px;word-break:normal}' . '.idn .host.warn svg{flex:0 0 auto}' . '.link{display:flex;align-items:center;width:58px;margin-top:27px;color:#98a2b3}' . '.link i{flex:1;border-top:2px dotted #d0d5dd}' . '.link em{width:24px;height:24px;flex:0 0 auto;margin:0 4px;border-radius:50%;background:#fff;border:1px solid #e4e7ec;' . 'display:inline-flex;align-items:center;justify-content:center}' . '.idline{font-size:16px;line-height:1.45;color:#475467;text-align:center;margin:0 0 18px}' . '.idline strong{color:#101828;font-weight:700}' // -- the access block ------------------------------------------------ . '.acc{border:1px solid #e4e7ec;border-radius:12px;overflow:hidden;background:#fff;margin:0 0 14px}' . '.acc .top{padding:14px 16px;background:#f7fefc}' . '.acc .top p{color:#475467;font-size:13px;margin:9px 0 0}' . '.badge{display:inline-flex;align-items:center;font-size:12px;font-weight:700;padding:3px 10px;border-radius:999px;' . 'background:#d1fadf;color:#027a48;border:1px solid #a6f4c5}' . '.badge.ro{background:#fef0c7;color:#b54708;border-color:#fdb022}' // -- who is approving ------------------------------------------------ . '.who{display:flex;align-items:center;gap:9px;font-size:13px;color:#667085;margin:0 0 14px;padding:0 2px}' . '.who strong{color:#101828;font-weight:600}' . '.avatar{width:24px;height:24px;border-radius:50%;flex:0 0 auto;background:#eaecf0;color:#475467;font-size:11px;' . 'font-weight:700;display:inline-flex;align-items:center;justify-content:center}' // -- the capability disclosure (native
, no script) ---------- . 'details.disc{border:1px solid #e4e7ec;border-radius:10px;margin:0 0 14px;background:#fff}' . 'details.disc>summary{list-style:none;cursor:pointer;padding:11px 14px;font-size:13.5px;font-weight:600;color:#344054;' . 'display:flex;align-items:center;gap:8px}' . 'details.disc>summary::-webkit-details-marker{display:none}' . 'details.disc>summary:focus-visible{outline:2px solid #00b884;outline-offset:2px;border-radius:9px}' . 'details.disc .chev{margin-left:auto;color:#98a2b3;transition:transform .15s}' . 'details.disc[open] .chev{transform:rotate(180deg)}' . 'details.disc .caps{margin:0;padding:2px 14px 14px}' . '.caps{list-style:none;margin:0 0 18px;padding:0;display:grid;gap:7px}' . '.caps li{display:flex;align-items:flex-start;gap:8px;font-size:13.5px;color:#344054}' . '.caps svg{color:#00b884;flex:0 0 auto;margin-top:3px}' // -- the footer note and the decision --------------------------------- . '.note{display:flex;align-items:center;gap:7px;color:#98a2b3;font-size:12px;margin:0 0 20px}' . '.note svg{flex:0 0 auto}' . '.actions{display:flex;gap:12px}' . 'button{flex:1;padding:12px;border-radius:10px;border:0;font:inherit;font-size:14px;font-weight:600;cursor:pointer;' . 'transition:filter .15s,background .15s,transform .05s}button:active{transform:translateY(1px)}' . 'button:focus-visible{outline:2px solid #00b884;outline-offset:2px}' . '.approve{background:#00b884;color:#fff;box-shadow:0 6px 16px rgba(0,184,132,.28)}.approve:hover{filter:brightness(1.06)}' . '.deny{background:#fff;color:#475467;border:1px solid #d0d5dd}.deny:hover{background:#f7f8fa}' . '@media (max-width:420px){.card{padding:22px}.idt{width:104px}.link{width:44px}.idline{font-size:15px}}'; } /** * Register every REST route. * * Registered directly rather than through `BaseAPI` (ADR-015): the transport * routes need `__return_true` with in-handler auth, the management routes * need `manage_options`, and `BaseAPI` has one permission callback per * class. * * @since 4.9.0 * * @return void */ public function register_rest() { // --- Transport ------------------------------------------------------- // `permission_callback` is `__return_true` because MCPServer does its // own token auth and has to answer a JSON-RPC 401 with the RFC 9728 // challenge, not a bare WordPress permission failure. register_rest_route( self::NS, '/mcp', [ [ 'methods' => 'POST', 'callback' => [ $this, 'rest_mcp' ], 'permission_callback' => '__return_true' ], [ 'methods' => 'GET', 'callback' => [ $this, 'rest_mcp_get' ], 'permission_callback' => '__return_true' ] ] ); // --- Discovery aliases (ADR-014) ------------------------------------- register_rest_route( self::NS, '/mcp/oauth/protected-resource', [ 'methods' => 'GET', 'callback' => [ $this, 'rest_protected_resource' ], 'permission_callback' => '__return_true' ] ); register_rest_route( self::NS, '/mcp/oauth/authorization-server', [ 'methods' => 'GET', 'callback' => [ $this, 'rest_authorization_server' ], 'permission_callback' => '__return_true' ] ); // --- OAuth 2.1 ------------------------------------------------------- // Public by necessity: a client has to reach these *before* it holds any // credential. `/authorize` is deliberately not here — see // AUTHORIZE_QUERY_VAR. register_rest_route( self::NS, '/mcp/oauth/register', [ 'methods' => 'POST', 'callback' => [ $this, 'rest_oauth_register' ], 'permission_callback' => '__return_true' ] ); register_rest_route( self::NS, '/mcp/oauth/token', [ 'methods' => 'POST', 'callback' => [ $this, 'rest_oauth_token' ], 'permission_callback' => '__return_true' ] ); // --- Management (the MCP admin page) --------------------------------- register_rest_route( self::NS, '/mcp/connection', [ 'methods' => 'GET', 'callback' => [ $this, 'rest_connection' ], 'permission_callback' => [ $this, 'admin_permission' ] ] ); register_rest_route( self::NS, '/mcp/connect', [ 'methods' => 'POST', 'callback' => [ $this, 'rest_connect' ], 'permission_callback' => [ $this, 'admin_permission' ], 'args' => [ 'read_only' => [ 'type' => 'boolean', 'required' => false, 'default' => false, 'sanitize_callback' => 'rest_sanitize_boolean', 'description' => __( 'Grant read-only access: no doc, term, FAQ or settings changes.', 'betterdocs' ) ] ] ] ); register_rest_route( self::NS, '/mcp/rotate', [ 'methods' => 'POST', 'callback' => [ $this, 'rest_rotate' ], 'permission_callback' => [ $this, 'admin_permission' ], 'args' => [ 'read_only' => [ 'type' => 'boolean', 'required' => false, 'sanitize_callback' => 'rest_sanitize_boolean', 'description' => __( 'Optionally set read-only on the new token; omit to keep the current scopes.', 'betterdocs' ) ] ] ] ); register_rest_route( self::NS, '/mcp/disconnect', [ 'methods' => 'POST', 'callback' => [ $this, 'rest_disconnect' ], 'permission_callback' => [ $this, 'admin_permission' ] ] ); register_rest_route( self::NS, '/mcp/apps', [ 'methods' => 'GET', 'callback' => [ $this, 'rest_apps' ], 'permission_callback' => [ $this, 'admin_permission' ] ] ); register_rest_route( self::NS, '/mcp/apps/revoke', [ 'methods' => 'POST', 'callback' => [ $this, 'rest_revoke_app' ], 'permission_callback' => [ $this, 'admin_permission' ], 'args' => [ 'client_id' => [ 'type' => 'string', 'required' => true, 'sanitize_callback' => 'sanitize_text_field', 'description' => __( 'The OAuth client_id to revoke.', 'betterdocs' ) ] ] ] ); register_rest_route( self::NS, '/mcp/self-test', [ 'methods' => 'POST', 'callback' => [ $this, 'rest_self_test' ], 'permission_callback' => [ $this, 'admin_permission' ] ] ); // Not gated by `enable_mcp` (ADR-013): the first question an admin asks // is "why is this not working", and a health report that needs the // feature switched on cannot answer it. register_rest_route( self::NS, '/mcp/health', [ 'methods' => 'GET', 'callback' => [ $this, 'rest_health' ], 'permission_callback' => [ $this, 'admin_permission' ], 'args' => [ 'user' => [ 'type' => 'integer', 'required' => false, 'description' => __( 'Report this user\'s capabilities instead of the caller\'s.', 'betterdocs' ) ] ] ] ); } /** * Capability gate for the management routes. * * @since 4.9.0 * * @return bool */ public function admin_permission() { return current_user_can( 'manage_options' ); } /** * POST `/mcp` — JSON-RPC over the wp-json fallback path. * * @since 4.9.0 * * @param \WP_REST_Request $request Incoming request. * @return \WP_REST_Response */ public function rest_mcp( $request ) { return MCPServer::handle( $request ); } /** * GET `/mcp` — there is nothing to read here. * * @since 4.9.0 * * @return \WP_REST_Response */ public function rest_mcp_get() { $response = new \WP_REST_Response( [ 'error' => 'method_not_allowed', 'message' => __( 'The BetterDocs MCP endpoint accepts POST only.', 'betterdocs' ) ], 405 ); $response->header( 'Allow', 'POST' ); $response->header( 'Cache-Control', 'no-store, private' ); return $response; } /** * GET `/mcp/oauth/protected-resource` — RFC 9728 metadata. * * @since 4.9.0 * * @return \WP_REST_Response */ public function rest_protected_resource() { return $this->discovery_response( 'protected-resource' ); } /** * GET `/mcp/oauth/authorization-server` — RFC 8414 metadata. * * @since 4.9.0 * * @return \WP_REST_Response */ public function rest_authorization_server() { return $this->discovery_response( 'authorization-server' ); } /** * A discovery document as a REST response, or a JSON 404 when MCP is off. * * @since 4.9.0 * * @param string $doc `protected-resource` or `authorization-server`. * @return \WP_REST_Response */ private function discovery_response( $doc ) { if ( ! self::is_enabled() ) { return new \WP_REST_Response( [ 'code' => 'betterdocs_mcp_disabled', 'message' => __( 'MCP is disabled on this site.', 'betterdocs' ), 'data' => [ 'status' => 404 ] ], 404 ); } $response = new \WP_REST_Response( self::discovery_document( $doc ), 200 ); $response->header( 'Cache-Control', 'public, max-age=3600' ); return $response; } /** * POST `/mcp/oauth/register` — RFC 7591 dynamic client registration. * * @since 4.9.0 * * @param \WP_REST_Request $request JSON body with `redirect_uris`. * @return \WP_REST_Response|\WP_Error */ public function rest_oauth_register( $request ) { if ( ! self::is_enabled() ) { return new \WP_Error( 'betterdocs_mcp_disabled', __( 'MCP is disabled on this site.', 'betterdocs' ), [ 'status' => 403 ] ); } $body = $request->get_json_params(); if ( ! is_array( $body ) ) { $body = []; } $result = MCPOAuth::register_client( $body ); if ( is_wp_error( $result ) ) { return $result; } $response = new \WP_REST_Response( $result, 201 ); $response->header( 'Cache-Control', 'no-store' ); return $response; } /** * POST `/mcp/oauth/token` — the code and refresh grants. * * OAuth sends `application/x-www-form-urlencoded`; JSON is accepted too. * Errors follow RFC 6749 §5.2 (`error` / `error_description`), not * WordPress' REST error shape, because that is what OAuth clients parse. * * @since 4.9.0 * * @param \WP_REST_Request $request Token request. * @return \WP_REST_Response */ public function rest_oauth_token( $request ) { if ( ! self::is_enabled() ) { return self::token_error( 'invalid_request', __( 'MCP is disabled on this site.', 'betterdocs' ), 403 ); } $body = $request->get_body_params(); if ( empty( $body ) ) { $json = $request->get_json_params(); $body = is_array( $json ) ? $json : []; } $body = array_map( 'strval', $body ); $result = MCPOAuth::exchange_token( $body ); if ( is_wp_error( $result ) ) { $data = $result->get_error_data(); $data = is_array( $data ) ? $data : []; return self::token_error( isset( $data['error'] ) ? (string) $data['error'] : 'invalid_request', isset( $data['error_description'] ) ? (string) $data['error_description'] : $result->get_error_message(), isset( $data['status'] ) ? (int) $data['status'] : 400 ); } $response = new \WP_REST_Response( $result, 200 ); $response->header( 'Cache-Control', 'no-store' ); $response->header( 'Pragma', 'no-cache' ); return $response; } /** * An RFC 6749 §5.2 error response. * * @since 4.9.0 * * @param string $error Error code. * @param string $description Human-readable description. * @param int $status HTTP status. * @return \WP_REST_Response */ private static function token_error( $error, $description, $status ) { $response = new \WP_REST_Response( [ 'error' => (string) $error, 'error_description' => (string) $description ], (int) $status ); $response->header( 'Cache-Control', 'no-store' ); return $response; } /** * GET `/mcp/connection` — pairing status for the admin page. * * @since 4.9.0 * * @return \WP_REST_Response */ public function rest_connection() { $this->ensure_connected(); $status = MCPPairing::public_status(); $status['enable_mcp'] = self::is_enabled(); $status['mcp_endpoint'] = MCPPairing::site_endpoint(); $status['mcp_endpoint_rest'] = MCPPairing::site_endpoint_fallback(); $status['authorize_url'] = MCPOAuth::authorize_url(); $status['issuer'] = MCPOAuth::issuer(); $status['discovery'] = [ 'protected_resource' => MCPOAuth::resource_metadata_url(), 'authorization_server' => rest_url( self::NS . '/mcp/oauth/authorization-server' ) ]; return rest_ensure_response( $status ); } /** * Make sure a connection token exists whenever an administrator looks at * the MCP page with MCP switched on. * * A site that has never minted one has no `config.cli`, no JSON block and * no AI prompt — and before this existed the page answered that by hiding * every client card behind a Connect button, including the two OAuth cards * that need no token at all (ADR-056). Minting is idempotent * ({@see MCPPairing::connect()} returns the existing record untouched) and * this method is only reached from `manage_options`-gated routes, so it * costs one option write on exactly one request per site. * * Read-write on purpose: a read-only pairing is a deliberate choice made * through `POST /mcp/connect` (ADR-038), not something to fall into. * * @since 4.9.0 * * @return void */ private function ensure_connected() { if ( self::is_enabled() && ! MCPPairing::is_connected() ) { MCPPairing::connect(); } } /** * Mint the pairing token when `enable_mcp` is switched on. * * Hooked to BetterDocs' own settings save, which is the path the page's * master switch writes through. Only an **off → on** transition mints: a * save that leaves the switch alone must not resurrect a pairing an * administrator deliberately disconnected. * * @since 4.9.0 * * @param bool $saved Whether the option write succeeded. * @param array $settings The settings as saved. * @param array $old_settings The settings as they were. * @return void */ public function mint_on_enable( $saved, $settings, $old_settings ) { $was = is_array( $old_settings ) && ! empty( $old_settings['enable_mcp'] ); $now = is_array( $settings ) && ! empty( $settings['enable_mcp'] ); if ( ! $was && $now ) { $this->ensure_connected(); } } /** * POST `/mcp/connect` — mint a connection token. * * @since 4.9.0 * * @param \WP_REST_Request $request Carries optional `read_only`. * @return \WP_REST_Response */ public function rest_connect( $request ) { return rest_ensure_response( MCPPairing::connect( (bool) $request->get_param( 'read_only' ) ) ); } /** * POST `/mcp/rotate` — mint a fresh token, killing the old one. * * @since 4.9.0 * * @param \WP_REST_Request $request Carries optional `read_only`. * @return \WP_REST_Response */ public function rest_rotate( $request ) { $read_only = null; if ( null !== $request->get_param( 'read_only' ) ) { $read_only = (bool) $request->get_param( 'read_only' ); } return rest_ensure_response( MCPPairing::rotate( $read_only ) ); } /** * POST `/mcp/disconnect` — revoke the pairing token and every OAuth grant. * * @since 4.9.0 * * @return \WP_REST_Response */ public function rest_disconnect() { return rest_ensure_response( MCPPairing::disconnect() ); } /** * GET `/mcp/apps` — the connected OAuth clients. * * @since 4.9.0 * * @return \WP_REST_Response */ public function rest_apps() { return rest_ensure_response( [ 'oauth_apps' => MCPOAuth::connected_apps() ] ); } /** * POST `/mcp/apps/revoke` — cut off one OAuth client and return the * refreshed list, so the UI updates in a single round trip. * * The pairing token has no per-client identity and is not listed here; it is * rotated from the connection card instead. * * @since 4.9.0 * * @param \WP_REST_Request $request Carries `client_id`. * @return \WP_REST_Response|\WP_Error */ public function rest_revoke_app( $request ) { $client_id = (string) $request->get_param( 'client_id' ); if ( '' === $client_id ) { return new \WP_Error( 'betterdocs_missing_client_id', __( 'A client_id is required to revoke an OAuth app.', 'betterdocs' ), [ 'status' => 400 ] ); } MCPOAuth::revoke_client( $client_id ); return rest_ensure_response( [ 'oauth_apps' => MCPOAuth::connected_apps() ] ); } /** * POST `/mcp/self-test` — the loopback ladder. * * A `POST` rather than a `GET` because it is the one diagnostic that makes * real outbound requests; nothing should trigger it by loading a page. * * @since 4.9.0 * * @return \WP_REST_Response */ public function rest_self_test() { return rest_ensure_response( $this->self_test->run() ); } /** * GET `/mcp/health` — the side-effect-free report. * * `?user=` reports another user's capability set instead of the caller's, * which is how support answers "why can this editor not create a doc?" * without logging in as them. The route is already `manage_options`, and the * report carries no secret for any user, so no further gate is needed — * but an id that is not a real user is refused rather than silently * reported as holding nothing. * * @since 4.9.0 * * @param \WP_REST_Request $request The request. * @return \WP_REST_Response|\WP_Error */ public function rest_health( $request ) { $user_id = null; if ( is_object( $request ) && null !== $request->get_param( 'user' ) ) { $user_id = (int) $request->get_param( 'user' ); if ( $user_id < 1 || ! get_user_by( 'id', $user_id ) ) { return new \WP_Error( 'betterdocs_mcp_unknown_user', __( 'No user with that id exists on this site.', 'betterdocs' ), [ 'status' => 404 ] ); } } return rest_ensure_response( $this->health->report( $user_id ) ); } /** * Admin notice when MCP is on but the bundled Abilities runtime is missing. * * That combination is almost always an incomplete package — a source archive, * or a zip built without `dependencies/vendor/`. Everything else still works: * OAuth discovers, tokens mint, clients connect, and `tools/list` is an empty * array served as success. Three layers each fail softly and compose into a * connector that connects and offers nothing, with no signal anywhere. Say it * where an administrator will look. * * @since 4.9.0 * * @return void */ public function warn_when_runtime_missing() { if ( ! self::is_enabled() || function_exists( 'wp_register_ability' ) ) { return; } if ( ! current_user_can( 'manage_options' ) ) { return; } printf( '

%s %s

', esc_html__( 'BetterDocs MCP: AI assistants will connect but see no tools.', 'betterdocs' ), 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' ) ); } /** * The live request's HTTP method. * * @since 4.9.0 * * @return string */ private static function request_method() { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- compared against a literal, never stored or output. return isset( $_SERVER['REQUEST_METHOD'] ) ? (string) wp_unslash( $_SERVER['REQUEST_METHOD'] ) : 'GET'; } /** * A header from the live PHP request. * * @since 4.9.0 * * @param string $name Header name. * @return string|null */ private static function server_header( $name ) { $key = 'HTTP_' . strtoupper( str_replace( '-', '_', (string) $name ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- a credential, compared in constant time downstream; it has to arrive verbatim. return isset( $_SERVER[ $key ] ) ? wp_unslash( $_SERVER[ $key ] ) : null; } /** * Emit a `WP_REST_Response` as an HTTP response and stop. * * @since 4.9.0 * * @param \WP_REST_Response $response Response to emit. * @return void */ private function emit_json( $response ) { $status = $response->get_status(); status_header( $status ); foreach ( $response->get_headers() as $name => $value ) { // Re-assert the status on every header: PHP special-cases // WWW-Authenticate and forces a 401 when no status is given, which // would silently mask the 429 a lockout answers with. header( $name . ': ' . $value, true, $status ); } $data = $response->get_data(); if ( null !== $data ) { header( 'Content-Type: application/json; charset=utf-8' ); echo wp_json_encode( $data ); } exit; } }