| 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 · %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 |
|