id, $this ); } /** * Permission callback handed to the Abilities API. * * Returns a plain bool on purpose. The Abilities API discards a `WP_Error` * from this callback and answers with its own generic * `ability_invalid_permissions` — and calls `_doing_it_wrong()` on the way, * so returning a typed error here would only add log noise. The typed * `capability_missing` lives in {@see self::execute_wrapper()}, which is the * path the MCP server takes. * * @since 4.9.0 * * @return bool */ public function permission_callback() { if ( ! $this->is_enabled() ) { return false; } return current_user_can( $this->capability ); } /** * Safety net against an ability that declares no gate at all. * * This only asserts that *some* capability was named; the capability itself is * per-ability (see {@see self::$capability}). * * @since 4.9.0 * * @return bool */ public function meets_capability_policy() { return is_string( $this->capability ) && '' !== $this->capability; } /** * Description for the current Pro state. * * Defaults to the static description. Stubs and the Pro knowledge-base * abilities override this to say what is actually true on *this* site — that * Pro is missing, inactive, or that a setting is off — so the tool list an * agent reads is never misleading. * * @since 4.9.0 * * @param array $pro_state Result of `ProState::get()`. * @return string */ // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found -- the state is the whole point of the override; the default just ignores it. public function describe( array $pro_state ) { return $this->description; } /** * Whether the feature behind this ability needs Pro. * * @since 4.9.0 * * @return bool */ public function requires_pro() { return (bool) $this->requires_pro; } /** * MCP-compatible annotations. Subclasses override. * * Key names are the Abilities API's; `MCPTools` maps them onto the * MCP spelling (`readOnlyHint`, `destructiveHint`, `idempotentHint`) when it * builds `tools/list`. * * @since 4.9.0 * * @return array */ public function get_annotations() { return [ 'readonly' => false, 'destructive' => false, 'idempotent' => false, 'priority' => 2.0, 'openWorldHint' => false ]; } /** * Run the ability with the surrounding contract: capability gate, action * hooks, and a `\Throwable` net. * * Registered as the ability's `execute_callback`, so on the Abilities API * path the capability check here is a second opinion the runtime has already * formed. It matters on the MCP path, which calls this directly and needs * the typed `capability_missing` rather than a bare false. * * `\Throwable`, not `\Exception`: a `TypeError` from a controller must come * back as a typed `upstream_error` naming the real reason, never as a fatal * that takes the JSON-RPC response with it. * * @since 4.9.0 * * @param array $input Validated input. * @return array|\WP_Error */ public function execute_wrapper( $input ) { if ( ! $this->is_enabled() || ! current_user_can( $this->capability ) ) { return AbilityError::capability_missing( $this->capability, $this->permission_phrase() ); } /** * Fires before an ability executes. * * @since 4.9.0 * * @param string $id Ability id. * @param array $input Validated input. */ do_action( 'betterdocs_before_ability_execute', $this->id, $input ); try { $output = $this->execute( $input ); } catch ( \Throwable $e ) { $output = AbilityError::upstream( $e->getMessage(), [ 'ability' => $this->id ] ); } /** * Fires after an ability executes, whatever the outcome. * * @since 4.9.0 * * @param string $id Ability id. * @param array $input Validated input. * @param array|\WP_Error $output What the ability returned. */ do_action( 'betterdocs_after_ability_execute', $this->id, $input, $output ); return $output; } /** * Call one of BetterDocs' own REST routes in-process and return its data. * * `rest_do_request()` still runs the route's `permission_callback`, but skips * the cookie-nonce check that only applies to real HTTP requests — correct * here, because the MCP server has already set the current user to whoever * granted the credential. * * Returns the **unwrapped** payload: an ability answers with plain data, not a * `{success, data}` envelope. * * @since 4.9.0 * * @param string $method HTTP verb. * @param string $route Route beneath the namespace, e.g. `/docs`. * @param array $params Query params for GET, body params otherwise. * @param string $rest_namespace REST namespace. Defaults to `betterdocs/v1`; the FAQ * routes live under the bare `betterdocs` namespace. * @return mixed|\WP_Error Response data, or the error the route produced. */ protected function dispatch( $method, $route, array $params = [], $rest_namespace = self::NS ) { $method = strtoupper( $method ); $request = new \WP_REST_Request( $method, '/' . trim( $rest_namespace, '/' ) . $route ); $request->set_header( 'Content-Type', 'application/json' ); if ( 'GET' === $method ) { $request->set_query_params( $params ); } else { $request->set_body( wp_json_encode( $params ) ); $request->set_body_params( $params ); } $response = rest_do_request( $request ); if ( $response->is_error() ) { return $response->as_error(); } return $response->get_data(); } /** * The same call as {@see self::dispatch()}, but handing back the whole * response instead of its data. * * Listing tools need it: `X-WP-Total` and `X-WP-TotalPages` live on the * response, and a page that reports the size of the page it returned rather * than the number of matches makes a client stop paging early. * * @since 4.9.0 * * @param string $method HTTP verb. * @param string $route Route beneath the namespace, e.g. `/docs`. * @param array $params Query params for GET, body params otherwise. * @param string $rest_namespace REST namespace. * @return \WP_REST_Response */ protected function dispatch_response( $method, $route, array $params = [], $rest_namespace = self::NS ) { $method = strtoupper( $method ); $request = new \WP_REST_Request( $method, '/' . trim( $rest_namespace, '/' ) . $route ); $request->set_header( 'Content-Type', 'application/json' ); if ( 'GET' === $method ) { $request->set_query_params( $params ); } else { $request->set_body( wp_json_encode( $params ) ); $request->set_body_params( $params ); } return rest_do_request( $request ); } /** * Whether a controller refused a page because it is past the last one. * * `WP_REST_Posts_Controller::get_items()` answers `rest_post_invalid_page_number` * the moment `page` exceeds the available pages and there is at least one * match; the terms controller does not (it pages by offset and returns an * empty page), so only the post-backed list tools ever meet this. It is not * a "not found": the collection exists, the page is merely empty. The terms * code is recognised too, for any controller that later adopts the same rule. * * @since 4.9.0 * * @param \WP_Error $error The controller error. * @return bool */ protected function is_page_out_of_range( \WP_Error $error ) { return in_array( $error->get_error_code(), [ 'rest_post_invalid_page_number', 'rest_term_invalid_page_number' ], true ); } /** * An empty page carrying the listing's real totals. * * The out-of-range error carries no counts, so the same query is re-run at * page 1 to read `X-WP-Total` / `X-WP-TotalPages` from the headers. The * result keeps paging honest: no items, the real `total` and `total_pages`, * and the `page` / `per_page` the caller asked for. A page past the end is a * normal empty answer, not an error (ADR-059, finding C). * * @since 4.9.0 * * @param string $route Route passed to dispatch_response (e.g. '/docs'). * @param array $params Query params of the failed request. * @param int $page The requested (out-of-range) page. * @param int $per_page Requested per_page. * @param string $rest_namespace REST namespace. * @return array */ protected function empty_page( $route, array $params, $page, $per_page, $rest_namespace = self::NS ) { $params['page'] = 1; $probe = $this->dispatch_response( 'GET', $route, $params, $rest_namespace ); $headers = $probe->is_error() ? [] : $probe->get_headers(); return [ 'items' => [], 'total' => isset( $headers['X-WP-Total'] ) ? (int) $headers['X-WP-Total'] : 0, 'total_pages' => isset( $headers['X-WP-TotalPages'] ) ? (int) $headers['X-WP-TotalPages'] : 1, 'page' => (int) $page, 'per_page' => (int) $per_page ]; } /** * Register the ability with the WordPress Abilities API. * * The `function_exists()` guard is inside the callback, never at hook * registration time: which copy of the API owns the global functions is * decided by load order, so a copy that lands late must still find us hooked * (see `AbilitiesRegistrar`). * * @since 4.9.0 * * @return void */ public function register() { if ( ! function_exists( 'wp_register_ability' ) ) { return; } wp_register_ability( $this->id, [ 'label' => $this->label, 'description' => $this->description, 'category' => $this->category, 'input_schema' => $this->get_input_schema(), 'output_schema' => $this->get_output_schema(), 'permission_callback' => [ $this, 'permission_callback' ], 'execute_callback' => [ $this, 'execute_wrapper' ], 'meta' => [ 'show_in_rest' => true, 'annotations' => $this->get_annotations(), 'mcp' => [ 'public' => false ], 'requires_pro' => $this->requires_pro() ] ] ); } /** * Ability id. * * @since 4.9.0 * * @return string */ public function get_id() { return $this->id; } /** * Human-readable label. * * @since 4.9.0 * * @return string */ public function get_label() { return $this->label; } /** * Static description. * * @since 4.9.0 * * @return string */ public function get_description() { return $this->description; } /** * Capability this ability is gated on. * * @since 4.9.0 * * @return string */ public function get_capability() { return $this->capability; } /** * The phrase `capability_missing` uses to say what was being attempted. * Defaults to the label, lowercased; subclasses may override for a better * sentence. * * @since 4.9.0 * * @return string */ protected function permission_phrase() { return '' !== $this->label ? lcfirst( $this->label ) : $this->id; } }