settings = $settings; $this->token = $token; } /** * The tool table. * * @return array Keyed by tool name. */ public function table() { $url_input = array( 'type' => 'object', 'properties' => array( 'url' => array( 'type' => 'string', 'description' => __( 'Absolute URL on this site.', 'oorank' ), ), ), 'required' => array( 'url' ), ); $tools = array( 'oorank_get_audit' => array( 'title' => __( 'Get a cached audit', 'oorank' ), 'description' => __( 'Return the stored AI-readiness audit for a URL: the score out of 100 and every check with its status and fix. Reads the cache only and never fetches the page, so it is safe to call for many URLs in a row. Prefer this over run_audit.', 'oorank' ), 'input' => $url_input, 'capability' => 'edit_posts', 'callback' => array( $this, 'get_audit' ), ), 'oorank_run_audit' => array( 'title' => __( 'Run a live audit', 'oorank' ), 'description' => __( 'Fetch a URL on this site the way a crawler would and score it out of 100. Rate limited; use get_audit unless a fresh result is genuinely needed.', 'oorank' ), 'input' => $url_input, 'capability' => 'edit_posts', 'callback' => array( $this, 'run_audit' ), ), 'oorank_get_site_report' => array( 'title' => __( 'Get the site report', 'oorank' ), 'description' => __( 'Return the site-wide readiness average and every audited page ranked worst first.', 'oorank' ), 'input' => array( 'type' => 'object', 'properties' => array( 'limit' => array( 'type' => 'integer', 'description' => __( 'Maximum pages to return (1-200).', 'oorank' ), ), ), ), 'capability' => 'edit_posts', 'callback' => array( $this, 'get_site_report' ), ), 'oorank_get_llms_txt' => array( 'title' => __( 'Read llms.txt', 'oorank' ), 'description' => __( 'Return the generated llms.txt index, or the full plain-text variant.', 'oorank' ), 'input' => array( 'type' => 'object', 'properties' => array( 'variant' => array( 'type' => 'string', 'enum' => array( 'index', 'full' ), 'description' => __( 'index for llms.txt, full for llms-full.txt.', 'oorank' ), ), ), ), 'capability' => 'edit_posts', 'callback' => array( $this, 'get_llms_txt' ), ), 'oorank_get_stale_content' => array( 'title' => __( 'List stale content', 'oorank' ), 'description' => __( 'List published content whose text has not changed inside the freshness threshold, newest first.', 'oorank' ), 'input' => array( 'type' => 'object', 'properties' => array( 'months' => array( 'type' => 'integer', 'description' => __( 'Staleness threshold in months. Defaults to the saved setting.', 'oorank' ), ), 'limit' => array( 'type' => 'integer', 'description' => __( 'Maximum items to return (1-100).', 'oorank' ), ), ), ), 'capability' => 'edit_posts', 'callback' => array( $this, 'get_stale_content' ), ), 'oorank_get_bot_settings' => array( 'title' => __( 'Get AI crawler access', 'oorank' ), 'description' => __( 'Return every known AI crawler with its operator, what it does with the content, and whether this site allows or blocks it.', 'oorank' ), 'input' => array( 'type' => 'object' ), 'capability' => Oorank_Roles::CAP, 'callback' => array( $this, 'get_bot_settings' ), ), 'oorank_get_schema_settings' => array( 'title' => __( 'Get structured-data settings', 'oorank' ), 'description' => __( 'Return the structured-data mode and publisher identity Oorank emits.', 'oorank' ), 'input' => array( 'type' => 'object' ), 'capability' => Oorank_Roles::CAP, 'callback' => array( $this, 'get_schema_settings' ), ), 'oorank_set_bot_access' => array( 'title' => __( 'Allow or block AI crawlers', 'oorank' ), 'description' => __( 'Set allow or block for one or more AI crawlers by slug, as listed by get_bot_settings. Unnamed crawlers keep their current state.', 'oorank' ), 'input' => array( 'type' => 'object', 'properties' => array( 'bots' => array( 'type' => 'object', 'description' => __( 'Map of bot slug to "allow" or "block".', 'oorank' ), 'additionalProperties' => array( 'type' => 'string', 'enum' => array( 'allow', 'block' ), ), ), ), 'required' => array( 'bots' ), ), 'capability' => Oorank_Roles::CAP, 'callback' => array( $this, 'set_bot_access' ), ), 'oorank_set_llms_settings' => array( 'title' => __( 'Configure llms.txt', 'oorank' ), 'description' => __( 'Set which post types llms.txt includes, whether it is generated or hand-written, and the manual body.', 'oorank' ), 'input' => array( 'type' => 'object', 'properties' => array( 'post_types' => array( 'type' => 'array', 'items' => array( 'type' => 'string' ), 'description' => __( 'Post type slugs to include.', 'oorank' ), ), 'mode' => array( 'type' => 'string', 'enum' => array( 'auto', 'manual' ), ), 'override' => array( 'type' => 'string', 'description' => __( 'Body used in manual mode.', 'oorank' ), ), ), ), 'capability' => Oorank_Roles::CAP, 'callback' => array( $this, 'set_llms_settings' ), ), 'oorank_rebuild_llms_txt' => array( 'title' => __( 'Rebuild llms.txt', 'oorank' ), 'description' => __( 'Regenerate both llms.txt endpoints from current content.', 'oorank' ), 'input' => array( 'type' => 'object' ), 'capability' => Oorank_Roles::CAP, 'callback' => array( $this, 'rebuild_llms_txt' ), ), ); /** * Filter the MCP tool table. * * Extension seam: Pro adds crawler-log and citation tools here. Entries * must supply title, description, input (JSON Schema), capability and a * callable receiving the argument array. * * @param array $tools The tool table keyed by name. * @param Oorank_Mcp_Tools $self This registry. */ return apply_filters( 'oorank_mcp_tools', $tools, $this ); } /** * The tools the current user may call, in MCP's tools/list shape. * * Tools the caller cannot use are omitted rather than listed and refused: * a client shows its user what the connection can do, and an entry that * always errors is worse than an absent one. * * @return array[] */ public function listing() { $listing = array(); foreach ( $this->table() as $name => $tool ) { if ( ! self::permits( $tool['capability'] ) ) { continue; } $listing[] = array( 'name' => $name, 'title' => $tool['title'], 'description' => $tool['description'], 'inputSchema' => empty( $tool['input'] ) ? array( 'type' => 'object' ) : $tool['input'], ); } return $listing; } /** * Call a tool. * * @param string $name Tool name. * @param array $args Arguments. * @return array|WP_Error Result payload, or an error to report as a tool * failure. */ public function invoke( $name, $args ) { $table = $this->table(); if ( ! isset( $table[ $name ] ) ) { return new WP_Error( 'oorank_mcp_unknown_tool', __( 'Unknown tool.', 'oorank' ) ); } $tool = $table[ $name ]; if ( ! self::permits( $tool['capability'] ) ) { return new WP_Error( 'oorank_mcp_forbidden', __( 'This connection is not permitted to use that tool.', 'oorank' ) ); } if ( ! $this->within_rate_limit( 'call', self::CALL_LIMIT ) ) { return new WP_Error( 'oorank_mcp_rate_limited', __( 'Too many tool calls this minute. Wait and retry.', 'oorank' ) ); } return call_user_func( $tool['callback'], is_array( $args ) ? $args : array() ); } /** * Whether the current user satisfies a tool's capability. * * Oorank's own capability is checked through Oorank_Roles, so an * administrator who has never re-saved the settings screen — and so has * never been granted the custom cap — is still recognised. * * @param string $capability Capability the tool declares. * @return bool */ private static function permits( $capability ) { if ( Oorank_Roles::CAP === $capability ) { return Oorank_Roles::can(); } return current_user_can( $capability ); } /** * Mirror the table into the Abilities API when something else has already * loaded it. Never a dependency: absent, nothing here changes. */ public function maybe_register_abilities() { if ( ! function_exists( 'wp_register_ability' ) ) { return; } // Called through a variable so the name is not a static reference to a // function that arrived in WordPress 6.9 — this plugin supports 6.0, // and the guard above is the whole contract. Plugin Check reads static // calls and would otherwise report a requirement that does not exist. $register = 'wp_register_ability'; foreach ( $this->table() as $name => $tool ) { $capability = $tool['capability']; $callback = $tool['callback']; $register( 'oorank/' . str_replace( 'oorank_', '', $name ), array( 'label' => $tool['title'], 'description' => $tool['description'], 'input_schema' => empty( $tool['input'] ) ? array( 'type' => 'object' ) : $tool['input'], 'execute_callback' => static function ( $input ) use ( $callback ) { return call_user_func( $callback, is_array( $input ) ? $input : array() ); }, 'permission_callback' => static function () use ( $capability ) { return self::permits( $capability ); }, ) ); } } /** * GET a cached audit. * * @param array $args Arguments. * @return array|WP_Error */ public function get_audit( $args ) { $url = $this->local_url( isset( $args['url'] ) ? $args['url'] : '' ); if ( is_wp_error( $url ) ) { return $url; } $cached = oorank()->audit->cached( $url ); if ( ! $cached ) { return array( 'url' => $url, 'cached' => false, 'note' => __( 'No stored audit for that URL. Call run_audit to produce one.', 'oorank' ), ); } return array_merge( array( 'cached' => true ), $cached ); } /** * Run a live audit. * * @param array $args Arguments. * @return array|WP_Error */ public function run_audit( $args ) { $url = $this->local_url( isset( $args['url'] ) ? $args['url'] : '' ); if ( is_wp_error( $url ) ) { return $url; } $cached = oorank()->audit->cached( $url ); if ( $cached ) { // The cache is the first line of the rate limit, not just a speed // optimisation: a loop over the site report costs nothing this way. return array_merge( array( 'cached' => true ), $cached ); } if ( ! $this->within_rate_limit( 'audit', self::AUDIT_LIMIT ) ) { return new WP_Error( 'oorank_mcp_rate_limited', __( 'Too many live audits this minute. Use get_audit for cached results.', 'oorank' ) ); } $result = oorank()->audit->audit( $url ); if ( is_wp_error( $result ) ) { return $result; } return array_merge( array( 'cached' => false ), $result ); } /** * The site report. * * @param array $args Arguments. * @return array */ public function get_site_report( $args ) { $report = oorank()->audit->report(); $limit = isset( $args['limit'] ) ? min( 200, max( 1, absint( $args['limit'] ) ) ) : 50; if ( isset( $report['pages'] ) && is_array( $report['pages'] ) ) { $report['returned'] = min( $limit, count( $report['pages'] ) ); $report['pages'] = array_slice( $report['pages'], 0, $limit ); } return $report; } /** * Read llms.txt content. * * @param array $args Arguments. * @return array */ public function get_llms_txt( $args ) { $variant = ( isset( $args['variant'] ) && 'full' === $args['variant'] ) ? 'full' : 'index'; $body = ( 'full' === $variant ) ? oorank()->llms_txt->get_full() : oorank()->llms_txt->get_index(); return array( 'variant' => $variant, 'bytes' => strlen( $body ), 'body' => $body, ); } /** * Stale content. * * @param array $args Arguments. * @return array */ public function get_stale_content( $args ) { $months = isset( $args['months'] ) ? absint( $args['months'] ) : 0; $limit = isset( $args['limit'] ) ? min( 100, max( 1, absint( $args['limit'] ) ) ) : 20; return array( 'items' => oorank()->freshness->stale( $months, $limit ) ); } /** * Bot registry plus current state. * * @return array */ public function get_bot_settings() { $states = oorank()->bots->states(); $bots = array(); foreach ( Oorank_Bot_Manager::registry() as $slug => $entry ) { $bots[] = array( 'slug' => $slug, 'name' => $entry['name'], 'operator' => $entry['operator'], 'category' => $entry['category'], 'state' => isset( $states[ $slug ] ) ? $states[ $slug ] : Oorank_Bot_Manager::ALLOW, ); } return array( 'bots' => $bots, 'content_signal' => oorank()->bots->content_signal(), ); } /** * Structured-data settings. * * @return array */ public function get_schema_settings() { $settings = $this->settings->get(); return isset( $settings['schema'] ) ? $settings['schema'] : array(); } /** * Set per-bot access. * * @param array $args Arguments. * @return array|WP_Error */ public function set_bot_access( $args ) { $input = isset( $args['bots'] ) && is_array( $args['bots'] ) ? $args['bots'] : array(); if ( empty( $input ) ) { return new WP_Error( 'oorank_mcp_bad_args', __( 'Pass a bots map of slug to allow or block.', 'oorank' ) ); } $known = Oorank_Bot_Manager::registry(); $settings = $this->settings->get(); $unknown = array(); $changed = array(); foreach ( $input as $slug => $state ) { $slug = sanitize_key( $slug ); if ( ! isset( $known[ $slug ] ) ) { $unknown[] = $slug; continue; } $settings['bots'][ $slug ] = ( 'block' === $state ) ? Oorank_Bot_Manager::BLOCK : Oorank_Bot_Manager::ALLOW; $changed[ $slug ] = $settings['bots'][ $slug ]; } if ( empty( $changed ) ) { return new WP_Error( 'oorank_mcp_bad_args', sprintf( /* translators: %s: comma-separated bot slugs. */ __( 'No known crawler named: %s.', 'oorank' ), implode( ', ', $unknown ) ) ); } // Through the store's own sanitizer, never straight into the option. $saved = $this->settings->update( $settings ); return array( 'changed' => $changed, 'unknown' => $unknown, 'bots' => $saved['bots'], ); } /** * Set the llms.txt group. * * @param array $args Arguments. * @return array */ public function set_llms_settings( $args ) { $settings = $this->settings->get(); if ( isset( $args['post_types'] ) && is_array( $args['post_types'] ) ) { $settings['llms']['post_types'] = $args['post_types']; } if ( isset( $args['mode'] ) ) { $settings['llms']['mode'] = $args['mode']; } if ( isset( $args['override'] ) ) { $settings['llms']['override'] = $args['override']; } $saved = $this->settings->update( $settings ); oorank()->llms_txt->rebuild(); return array( 'llms' => $saved['llms'] ); } /** * Rebuild both llms.txt endpoints. * * @return array */ public function rebuild_llms_txt() { oorank()->llms_txt->rebuild(); return array( 'rebuilt' => true, 'bytes' => array( 'index' => strlen( oorank()->llms_txt->get_index() ), 'full' => strlen( oorank()->llms_txt->get_full() ), ), ); } /** * Validate a URL as belonging to this site. * * @param mixed $url Raw URL. * @return string|WP_Error */ private function local_url( $url ) { $url = esc_url_raw( trim( (string) $url ) ); if ( '' === $url ) { return new WP_Error( 'oorank_mcp_bad_args', __( 'Pass an absolute URL.', 'oorank' ) ); } $host = wp_parse_url( $url, PHP_URL_HOST ); $home = wp_parse_url( home_url(), PHP_URL_HOST ); if ( ! $host || strtolower( $host ) !== strtolower( (string) $home ) ) { return new WP_Error( 'oorank_mcp_bad_args', __( 'That URL is not on this site.', 'oorank' ) ); } return $url; } /** * Per-token, per-minute counter. * * @param string $bucket Bucket name. * @param int $limit Calls allowed per minute. * @return bool True while under the ceiling. */ private function within_rate_limit( $bucket, $limit ) { $key = 'oorank_mcp_rl_' . $bucket . '_' . $this->token->fingerprint(); $count = (int) get_transient( $key ); if ( $count >= $limit ) { return false; } set_transient( $key, $count + 1, MINUTE_IN_SECONDS ); return true; } }