header( 'WWW-Authenticate', self::challenge_header() ); $response->header( 'Retry-After', (string) MCPRateLimiter::retry_after() ); return self::decorate( $response ); } $user_id = self::authorize( $presented ); if ( null === $user_id ) { if ( '' !== $presented ) { MCPRateLimiter::record_failure(); } $response = self::error_response( null, self::UNAUTHORIZED, __( 'Unauthorized: invalid or missing connection token.', 'betterdocs' ), 401 ); // RFC 9728: point OAuth-capable clients at the protected-resource // metadata so they can start the flow. $response->header( 'WWW-Authenticate', self::challenge_header() ); return self::decorate( $response ); } if ( ! self::impersonate( $user_id ) ) { // The credential is genuine; the user behind it is gone or no // longer allowed. That is a dead grant, not a missing one. return self::decorate( self::dead_grant_response() ); } MCPRateLimiter::clear(); $raw = (string) $request->get_body(); $msg = json_decode( $raw, true ); if ( null === $msg && JSON_ERROR_NONE !== json_last_error() ) { return self::decorate( self::error_response( null, self::PARSE_ERROR, __( 'Parse error: body is not valid JSON.', 'betterdocs' ), 400 ) ); } // A batch is an array of messages. Answer each one; per JSON-RPC, drop // the notifications from the reply. if ( is_array( $msg ) && array_key_exists( 0, $msg ) ) { $responses = []; foreach ( $msg as $one ) { $answer = self::dispatch( is_array( $one ) ? $one : [] ); if ( null !== $answer ) { $responses[] = $answer; } } if ( empty( $responses ) ) { return self::decorate( new \WP_REST_Response( null, 202 ) ); } return self::decorate( new \WP_REST_Response( $responses, 200 ) ); } if ( ! is_array( $msg ) ) { return self::decorate( self::error_response( null, self::INVALID_REQUEST, __( 'Invalid request.', 'betterdocs' ), 400 ) ); } $response = self::dispatch( $msg ); if ( null === $response ) { // A notification — acknowledged, with no body. return self::decorate( new \WP_REST_Response( null, 202 ) ); } return self::decorate( new \WP_REST_Response( $response, 200 ) ); } /** * Whether the MCP endpoint is switched on. * * `MCPManager` owns the toggle and its filter; the setting is also read * directly here, so this class is testable and measurable on its own. The two must agree — `MCPManager::is_enabled()` reads the same key. * * @since 4.9.0 * * @return bool */ private static function is_enabled() { if ( class_exists( __NAMESPACE__ . '\\MCPManager' ) && method_exists( __NAMESPACE__ . '\\MCPManager', 'is_enabled' ) ) { return (bool) MCPManager::is_enabled(); } if ( ! function_exists( 'betterdocs' ) ) { return false; } $plugin = betterdocs(); if ( ! is_object( $plugin ) || ! isset( $plugin->settings ) || ! is_object( $plugin->settings ) ) { return false; } return ! empty( $plugin->settings->get( 'enable_mcp' ) ); } /** * Dispatch one JSON-RPC message. * * @since 4.9.0 * * @param array $msg Decoded JSON-RPC message. * @return array|null The response envelope, or null for a notification. */ private static function dispatch( array $msg ) { $method = isset( $msg['method'] ) ? (string) $msg['method'] : ''; $id = isset( $msg['id'] ) ? $msg['id'] : null; $params = isset( $msg['params'] ) && is_array( $msg['params'] ) ? $msg['params'] : []; // A message with no `id` is a notification: acknowledged, never answered. $is_notification = ! array_key_exists( 'id', $msg ); switch ( $method ) { case 'initialize': return self::result( $id, [ 'protocolVersion' => self::PROTOCOL_VERSION, 'capabilities' => [ 'tools' => [ 'listChanged' => false ] ], 'serverInfo' => [ 'name' => 'betterdocs', 'version' => defined( 'BETTERDOCS_VERSION' ) ? BETTERDOCS_VERSION : '0.0.0' ], 'instructions' => self::instructions() ] ); case 'ping': return self::result( $id, (object) [] ); case 'tools/list': $tools = MCPTools::list(); // An empty catalog while MCP is enabled means the Abilities // runtime never loaded, and the client sees a clean, useless // connection. Leave a trail for whoever debugs it; the health // report carries the loud version. if ( empty( $tools ) && defined( 'WP_DEBUG' ) && WP_DEBUG ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- WP_DEBUG-gated diagnostic. error_log( '[BD-MCP] tools/list returned 0 tools. ' . AbilitiesRegistrar::summary() ); } return self::result( $id, [ 'tools' => $tools ] ); case 'tools/call': return self::call_tool( $id, $params ); default: if ( $is_notification || 0 === strpos( $method, 'notifications/' ) ) { return null; } return self::error( $id, self::METHOD_NOT_FOUND, 'Method not found: ' . $method ); } } /** * The `instructions` string handed to the client on `initialize`. * * One paragraph, because clients put it straight into the model's context. * * @since 4.9.0 * * @return string */ private static function instructions() { return __( 'Call bd-get-status first: it reports the BetterDocs and BetterDocs Pro versions, which capabilities the connected user holds, and which features are switched on, so you can tell a refusal apart from a misconfiguration before you try anything. Every tool describes its own availability — a tool that needs BetterDocs Pro, or a setting that is currently off, says so in its description and returns a typed error explaining what would make it work. Prefer names over ids where a tool accepts both; it will find or create the matching term.', 'betterdocs' ); } /** * Run a `tools/call` and wrap the answer as MCP content. * * A tool-level failure is a *successful* JSON-RPC response carrying * `isError: true` (per MCP), so the model reads the typed object instead of * the transport swallowing it. The same object is sent twice: as JSON text, * which every client renders, and as `structuredContent`, which the clients * that understand it can act on (ADR-016). * * @since 4.9.0 * * @param mixed $id JSON-RPC id. * @param array $params `{ name: string, arguments: array }`. * @return array */ private static function call_tool( $id, array $params ) { $name = isset( $params['name'] ) ? (string) $params['name'] : ''; $args = isset( $params['arguments'] ) && is_array( $params['arguments'] ) ? $params['arguments'] : []; if ( '' === $name ) { return self::error( $id, self::INVALID_PARAMS, __( 'Missing tool name.', 'betterdocs' ) ); } $result = MCPTools::invoke( $name, $args ); if ( is_wp_error( $result ) ) { return self::result( $id, self::content( self::error_payload( $result ), true ) ); } return self::result( $id, self::content( $result, false ) ); } /** * The typed object carried by a `WP_Error` from the ability layer. * * `AbilityError` always puts `error` and `message` in the data, but a * `WP_Error` from anywhere else may not, so both are filled in from the code * and the message when they are missing. * * @since 4.9.0 * * @param \WP_Error $error The error. * @return array */ private static function error_payload( \WP_Error $error ) { $data = $error->get_error_data(); if ( ! is_array( $data ) ) { $data = []; } if ( ! isset( $data['error'] ) ) { $data['error'] = (string) $error->get_error_code(); } if ( ! isset( $data['message'] ) ) { $data['message'] = (string) $error->get_error_message(); } return $data; } /** * Wrap a payload as an MCP tool result. * * @since 4.9.0 * * @param array $payload Result or typed error object. * @param bool $is_error Whether this is a tool-level failure. * @return array */ private static function content( array $payload, $is_error ) { return [ 'content' => [ [ 'type' => 'text', 'text' => wp_json_encode( $payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) ] ], 'structuredContent' => $payload, 'isError' => (bool) $is_error ]; } /** * Validate the presented Bearer credential. * * Pairing token first, then OAuth. Each path sets the read-only override * from its own grant, so the two can disagree without one leaking into the * other. * * @since 4.9.0 * * @param string $presented Credential from the request. * @return int|null The granting user's id, or null when nothing matched. */ private static function authorize( $presented ) { $presented = (string) $presented; if ( '' === $presented ) { return null; } // Path 1: the static per-site pairing token. Compared through // verify_token(), which checks the stored hash — the token is encrypted // at rest and never compared in the clear (ADR-007). if ( MCPPairing::verify_token( $presented ) ) { MCPTools::set_read_only_override( MCPPairing::is_read_only() ); MCPPairing::touch_last_used(); return MCPPairing::user_id(); } // Path 2: an OAuth 2.1 access token. Its own granted scope decides // read-only, independent of the pairing token's scopes. $grant = MCPOAuth::validate_token( $presented ); if ( is_array( $grant ) ) { MCPTools::set_read_only_override( MCPOAuth::scope_is_read_only( $grant['scope'] ) ); return (int) $grant['user_id']; } return null; } /** * Run the request as the user who granted the credential. * * Refuses when that user no longer exists or no longer holds `edit_docs` — * a deleted or demoted user's grants die with them (ADR-006). Every * ability then re-checks its own capability on top of this floor. * * @since 4.9.0 * * @param int $user_id Granting user id. * @return bool */ private static function impersonate( $user_id ) { $user_id = (int) $user_id; if ( $user_id <= 0 ) { return false; } $user = get_user_by( 'id', $user_id ); if ( ! $user || ! user_can( $user, self::IMPERSONATION_CAPABILITY ) ) { return false; } wp_set_current_user( $user_id ); return true; } /** * The 403 for a credential whose user cannot be impersonated. * * Carries the typed object in `error.data` so a client gets the same * vocabulary here as it would from a tool (ADR-016). * * @since 4.9.0 * * @return \WP_REST_Response */ private static function dead_grant_response() { $typed = AbilityError::capability_missing( self::IMPERSONATION_CAPABILITY, __( 'use this MCP connection', 'betterdocs' ) ); $payload = self::error_payload( $typed ); $envelope = self::error( null, self::UNAUTHORIZED, $payload['message'] ); $envelope['error']['data'] = $payload; return new \WP_REST_Response( $envelope, 403 ); } /** * The RFC 9728 `WWW-Authenticate` challenge value. * * Points at the REST alias rather than `/.well-known/…`, because a host that * intercepts well-known paths would otherwise send the client somewhere that * is not us (ADR-014); the alias is filterable. * * @since 4.9.0 * * @return string */ private static function challenge_header() { return sprintf( 'Bearer resource_metadata="%s"', MCPOAuth::resource_metadata_url() ); } /** * Pull the token from `Authorization: Bearer …`. * * `MCPManager` sets this header synthetically when the token arrived * as a path segment of the pretty endpoint, so there is one place that reads * a credential. * * @since 4.9.0 * * @param \WP_REST_Request $request Incoming request. * @return string */ private static function extract_token( $request ) { $auth = $request->get_header( 'authorization' ); if ( is_string( $auth ) && preg_match( '/^Bearer\s+(.+)$/i', trim( $auth ), $matches ) ) { return trim( $matches[1] ); } return ''; } /** * Headers every MCP response carries. * * `Cache-Control: no-store, private` is not optional: the pretty endpoint * can carry the pairing token in its path, so a shared cache or proxy * holding a response keyed on that URL would keep an admin-equivalent * credential in its store (ADR-007). * * @since 4.9.0 * * @param \WP_REST_Response $response Response to decorate. * @return \WP_REST_Response */ private static function decorate( $response ) { $response->header( 'MCP-Protocol-Version', self::PROTOCOL_VERSION ); $response->header( 'Cache-Control', 'no-store, private' ); $response->header( 'Content-Type', 'application/json' ); return $response; } /** * Opt-in diagnostic tap: define `BETTERDOCS_MCP_DEBUG` in `wp-config.php`. * * Logs the method, whether a credential was presented, and the tool name — * never the credential, never the parameters, which routinely carry document * content. * * @since 4.9.0 * * @param \WP_REST_Request $request Incoming request. * @return void */ private static function debug_tap( $request ) { if ( ! defined( 'BETTERDOCS_MCP_DEBUG' ) || ! BETTERDOCS_MCP_DEBUG ) { return; } $msg = json_decode( (string) $request->get_body(), true ); $method = is_array( $msg ) && isset( $msg['method'] ) ? (string) $msg['method'] : '?'; $tool = is_array( $msg ) && isset( $msg['params']['name'] ) ? (string) $msg['params']['name'] : '-'; // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- opt-in debug tap. error_log( sprintf( '[BD-MCP] in method=%s tool=%s auth=%s', $method, $tool, $request->get_header( 'authorization' ) ? 'yes' : 'no' ) ); } /** * Build a JSON-RPC success envelope. * * @since 4.9.0 * * @param mixed $id JSON-RPC id. * @param mixed $result Result payload. * @return array */ private static function result( $id, $result ) { return [ 'jsonrpc' => '2.0', 'id' => $id, 'result' => $result ]; } /** * Build a JSON-RPC error envelope. * * @since 4.9.0 * * @param mixed $id JSON-RPC id. * @param int $code JSON-RPC error code. * @param string $message Error message. * @return array */ private static function error( $id, $code, $message ) { return [ 'jsonrpc' => '2.0', 'id' => $id, 'error' => [ 'code' => (int) $code, 'message' => (string) $message ] ]; } /** * Build a transport-level error response with an HTTP status. * * @since 4.9.0 * * @param mixed $id JSON-RPC id. * @param int $code JSON-RPC error code. * @param string $message Error message. * @param int $http HTTP status. * @return \WP_REST_Response */ private static function error_response( $id, $code, $message, $http ) { return new \WP_REST_Response( self::error( $id, $code, $message ), (int) $http ); } }