settings = $settings; $this->llms = $llms; $this->integrations = $integrations; } /** * Register hooks. * * @return void */ public function init() { add_action( 'rest_api_init', array( $this, 'register_routes' ) ); } /** * Register public read-only REST routes. * * @return void */ public function register_routes() { register_rest_route( self::REST_NAMESPACE, '/tools', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'rest_tools' ), 'permission_callback' => array( $this, 'public_read_permission' ), ) ); register_rest_route( self::REST_NAMESPACE, '/site', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'rest_site' ), 'permission_callback' => array( $this, 'public_read_permission' ), ) ); register_rest_route( self::REST_NAMESPACE, '/search', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'rest_search' ), 'permission_callback' => array( $this, 'public_read_permission' ), 'args' => array( 'query' => array( 'description' => __( 'Public search query.', 'agizoai-agentic-browsing-optimizer' ), 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ), 'q' => array( 'description' => __( 'Public search query alias.', 'agizoai-agentic-browsing-optimizer' ), 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field', ), 'post_type' => array( 'description' => __( 'Optional selected public post type.', 'agizoai-agentic-browsing-optimizer' ), 'type' => 'string', 'sanitize_callback' => 'sanitize_key', ), 'per_page' => array( 'description' => __( 'Number of results, between 1 and 20.', 'agizoai-agentic-browsing-optimizer' ), 'type' => 'integer', 'sanitize_callback' => 'absint', ), ), ) ); register_rest_route( self::REST_NAMESPACE, '/content/(?P\d+)', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'rest_content' ), 'permission_callback' => array( $this, 'public_read_permission' ), 'args' => array( 'id' => array( 'description' => __( 'Public post/page/content ID.', 'agizoai-agentic-browsing-optimizer' ), 'type' => 'integer', 'sanitize_callback' => 'absint', ), ), ) ); register_rest_route( self::REST_NAMESPACE, '/recent', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'rest_recent' ), 'permission_callback' => array( $this, 'public_read_permission' ), ) ); register_rest_route( self::REST_NAMESPACE, '/menus', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'rest_menus' ), 'permission_callback' => array( $this, 'public_read_permission' ), ) ); register_rest_route( self::REST_NAMESPACE, '/taxonomies', array( 'methods' => WP_REST_Server::READABLE, 'callback' => array( $this, 'rest_taxonomies' ), 'permission_callback' => array( $this, 'public_read_permission' ), ) ); } /** * Permission callback for intentionally public read-only agent endpoints. * * These routes only expose selected published public content and are disabled * when the administrator turns off public agent tools or REST discovery. * * @return bool */ public function public_read_permission() { return $this->is_enabled(); } /** * Return enabled state. * * @return bool */ public function is_enabled() { return (bool) $this->settings->get( 'agent_tools_enabled', 1 ) && (bool) $this->settings->get( 'agent_tools_rest_enabled', 1 ); } /** * Return base REST URL. * * @return string */ public function get_base_url() { return rest_url( self::REST_NAMESPACE . '/' ); } /** * Return registry endpoint URL. * * @return string */ public function get_registry_url() { return rest_url( self::REST_NAMESPACE . '/tools' ); } /** * Return active public tool registry. * * @return array> */ public function get_tools() { $options = $this->settings->get_options(); $prefix = $this->tool_prefix(); $tools = array(); if ( empty( $options['agent_tools_enabled'] ) || empty( $options['agent_tools_rest_enabled'] ) ) { return array(); } $tools[] = $this->tool_definition( $prefix . '_get_site_info', __( 'Get public site information', 'agizoai-agentic-browsing-optimizer' ), __( 'Returns public site name, description, language, purpose, key URLs and selected public content types.', 'agizoai-agentic-browsing-optimizer' ), rest_url( self::REST_NAMESPACE . '/site' ), array() ); if ( ! empty( $options['agent_tools_search_enabled'] ) ) { $tools[] = $this->tool_definition( $prefix . '_search_public_content', __( 'Search public content', 'agizoai-agentic-browsing-optimizer' ), __( 'Searches published public content selected for agent discovery. Private, draft, password-protected and skipped content is excluded.', 'agizoai-agentic-browsing-optimizer' ), rest_url( self::REST_NAMESPACE . '/search' ), array( 'query' => __( 'Search phrase.', 'agizoai-agentic-browsing-optimizer' ), 'post_type' => __( 'Optional public post type included in plugin settings.', 'agizoai-agentic-browsing-optimizer' ), 'per_page' => __( 'Optional result limit from 1 to 20.', 'agizoai-agentic-browsing-optimizer' ), ) ); } if ( ! empty( $options['agent_tools_content_enabled'] ) ) { $tools[] = $this->tool_definition( $prefix . '_get_public_content_summary', __( 'Get public content summary', 'agizoai-agentic-browsing-optimizer' ), __( 'Returns a safe summary and metadata for one published public post, page, product or custom post type item.', 'agizoai-agentic-browsing-optimizer' ), rest_url( self::REST_NAMESPACE . '/content/{id}' ), array( 'id' => __( 'Public WordPress content ID.', 'agizoai-agentic-browsing-optimizer' ), ) ); } if ( ! empty( $options['agent_tools_recent_enabled'] ) ) { $tools[] = $this->tool_definition( $prefix . '_get_recent_public_content', __( 'Get recent public content', 'agizoai-agentic-browsing-optimizer' ), __( 'Returns recent public content from the selected agent-facing post types.', 'agizoai-agentic-browsing-optimizer' ), rest_url( self::REST_NAMESPACE . '/recent' ), array( 'post_type' => __( 'Optional public post type included in plugin settings.', 'agizoai-agentic-browsing-optimizer' ), 'per_page' => __( 'Optional result limit from 1 to 20.', 'agizoai-agentic-browsing-optimizer' ), ) ); } if ( ! empty( $options['agent_tools_menus_enabled'] ) ) { $tools[] = $this->tool_definition( $prefix . '_get_navigation_menu', __( 'Get public navigation menus', 'agizoai-agentic-browsing-optimizer' ), __( 'Returns public navigation menu locations and public menu items.', 'agizoai-agentic-browsing-optimizer' ), rest_url( self::REST_NAMESPACE . '/menus' ), array() ); } if ( ! empty( $options['agent_tools_taxonomies_enabled'] ) ) { $tools[] = $this->tool_definition( $prefix . '_get_public_taxonomies', __( 'Get public sections and taxonomies', 'agizoai-agentic-browsing-optimizer' ), __( 'Returns public categories, tags and selected taxonomy sections included in the agent-facing configuration.', 'agizoai-agentic-browsing-optimizer' ), rest_url( self::REST_NAMESPACE . '/taxonomies' ), array() ); } return array_values( apply_filters( 'agizoai_agentic_public_tools', $tools, $options ) ); } /** * Return short summary for dashboards/reports. * * @return array */ public function summarize_tools() { $tools = $this->get_tools(); return array( 'enabled' => $this->is_enabled(), 'count' => count( $tools ), 'base_url' => $this->get_base_url(), 'registry_url' => $this->get_registry_url(), 'tools' => $tools, ); } /** * REST callback: tool registry. * * @return WP_REST_Response */ public function rest_tools() { if ( ! $this->is_enabled() ) { return new WP_REST_Response( array( 'enabled' => false, 'tools' => array(), 'message' => __( 'AgizoAI public agent tools are disabled.', 'agizoai-agentic-browsing-optimizer' ), ), 200 ); } return rest_ensure_response( array( 'enabled' => true, 'site' => home_url( '/' ), 'namespace' => self::REST_NAMESPACE, 'mode' => 'public_read_only', 'safe_mode' => (bool) $this->settings->get( 'safe_mode', 1 ), 'generated_at' => current_time( 'mysql' ), 'security_notes' => $this->security_notes(), 'tools' => $this->get_tools(), ) ); } /** * REST callback: public site info. * * @return WP_REST_Response */ public function rest_site() { if ( ! $this->is_enabled() ) { return $this->disabled_response(); } $options = $this->settings->get_options(); return rest_ensure_response( array( 'name' => get_bloginfo( 'name' ), 'description' => get_bloginfo( 'description' ), 'url' => home_url( '/' ), 'language' => get_bloginfo( 'language' ), 'timezone' => wp_timezone_string(), 'site_type' => $options['site_type'], 'site_purpose' => $options['site_purpose'], 'privacy_policy' => get_privacy_policy_url(), 'llms_txt' => home_url( '/llms.txt' ), 'llms_full_txt' => home_url( '/llms-full.txt' ), 'tool_registry' => $this->get_registry_url(), 'public_post_types' => array_values( (array) $options['include_post_types'] ), 'public_taxonomies' => array_values( (array) $options['include_taxonomies'] ), 'seo_integrations' => $this->integrations ? $this->integrations->summarize_integrations() : array(), ) ); } /** * REST callback: search public content. * * @param WP_REST_Request $request Request. * @return WP_REST_Response */ public function rest_search( WP_REST_Request $request ) { if ( ! $this->is_enabled() || ! $this->settings->get( 'agent_tools_search_enabled', 1 ) ) { return $this->disabled_response(); } $query = (string) $request->get_param( 'query' ); $query = '' !== $query ? $query : (string) $request->get_param( 'q' ); $query = sanitize_text_field( $query ); $per_page = $this->bounded_per_page( $request->get_param( 'per_page' ) ); $type = $this->requested_post_type( $request->get_param( 'post_type' ) ); $types = $type ? array( $type ) : $this->selected_post_types(); if ( '' === trim( $query ) ) { return rest_ensure_response( array( 'query' => '', 'results' => array(), 'message' => __( 'Provide a query parameter to search public content.', 'agizoai-agentic-browsing-optimizer' ), ) ); } $posts = get_posts( array( 'post_type' => $types, 'post_status' => 'publish', 's' => $query, 'posts_per_page' => $per_page, 'orderby' => 'relevance', 'no_found_rows' => true, 'ignore_sticky_posts' => true, 'update_post_meta_cache' => true, 'update_post_term_cache' => false, 'suppress_filters' => false, ) ); $results = $this->format_posts( $posts ); return rest_ensure_response( array( 'query' => $query, 'post_types' => $types, 'count' => count( $results ), 'results' => $results, ) ); } /** * REST callback: single public content summary. * * @param WP_REST_Request $request Request. * @return WP_REST_Response */ public function rest_content( WP_REST_Request $request ) { if ( ! $this->is_enabled() || ! $this->settings->get( 'agent_tools_content_enabled', 1 ) ) { return $this->disabled_response(); } $post = get_post( absint( $request->get_param( 'id' ) ) ); if ( ! $post instanceof WP_Post || ! $this->is_public_post_allowed( $post ) ) { return new WP_REST_Response( array( 'error' => 'not_found', 'message' => __( 'Public content was not found or is not exposed to agent tools.', 'agizoai-agentic-browsing-optimizer' ), ), 404 ); } return rest_ensure_response( $this->format_post( $post, true ) ); } /** * REST callback: recent public content. * * @param WP_REST_Request $request Request. * @return WP_REST_Response */ public function rest_recent( WP_REST_Request $request ) { if ( ! $this->is_enabled() || ! $this->settings->get( 'agent_tools_recent_enabled', 1 ) ) { return $this->disabled_response(); } $per_page = $this->bounded_per_page( $request->get_param( 'per_page' ) ); $type = $this->requested_post_type( $request->get_param( 'post_type' ) ); $types = $type ? array( $type ) : $this->selected_post_types(); $posts = get_posts( array( 'post_type' => $types, 'post_status' => 'publish', 'posts_per_page' => $per_page, 'orderby' => 'modified', 'order' => 'DESC', 'no_found_rows' => true, 'ignore_sticky_posts' => true, 'update_post_meta_cache' => true, 'update_post_term_cache' => false, 'suppress_filters' => false, ) ); $results = $this->format_posts( $posts ); return rest_ensure_response( array( 'post_types' => $types, 'count' => count( $results ), 'results' => $results, ) ); } /** * REST callback: navigation menus. * * @return WP_REST_Response */ public function rest_menus() { if ( ! $this->is_enabled() || ! $this->settings->get( 'agent_tools_menus_enabled', 1 ) ) { return $this->disabled_response(); } $locations = get_nav_menu_locations(); $registered = get_registered_nav_menus(); $menus = array(); foreach ( $locations as $location => $menu_id ) { $items = wp_get_nav_menu_items( $menu_id ); $clean = array(); if ( is_array( $items ) ) { foreach ( $items as $item ) { if ( empty( $item->url ) || empty( $item->title ) ) { continue; } $clean[] = array( 'id' => absint( $item->ID ), 'title' => wp_strip_all_tags( $item->title ), 'url' => esc_url_raw( $item->url ), 'parent' => absint( $item->menu_item_parent ), 'type' => sanitize_key( $item->type ), ); } } $menus[] = array( 'location' => sanitize_key( $location ), 'location_label' => isset( $registered[ $location ] ) ? $registered[ $location ] : $location, 'menu_id' => absint( $menu_id ), 'items' => $clean, ); } return rest_ensure_response( array( 'count' => count( $menus ), 'menus' => $menus, ) ); } /** * REST callback: selected public taxonomies. * * @return WP_REST_Response */ public function rest_taxonomies() { if ( ! $this->is_enabled() || ! $this->settings->get( 'agent_tools_taxonomies_enabled', 1 ) ) { return $this->disabled_response(); } $taxonomies = (array) $this->settings->get( 'include_taxonomies', array() ); $results = array(); foreach ( $taxonomies as $taxonomy ) { $terms = get_terms( array( 'taxonomy' => sanitize_key( $taxonomy ), 'hide_empty' => true, 'number' => 50, ) ); if ( is_wp_error( $terms ) || ! is_array( $terms ) ) { continue; } $clean_terms = array(); foreach ( $terms as $term ) { $link = get_term_link( $term ); if ( is_wp_error( $link ) ) { continue; } $clean_terms[] = array( 'id' => absint( $term->term_id ), 'name' => $term->name, 'slug' => $term->slug, 'url' => esc_url_raw( $link ), 'count' => absint( $term->count ), 'description' => $this->short_text( $term->description, 36 ), ); } $results[] = array( 'taxonomy' => sanitize_key( $taxonomy ), 'terms' => $clean_terms, ); } return rest_ensure_response( array( 'count' => count( $results ), 'taxonomies' => $results, ) ); } /** * Format a tool definition. * * @param string $name Tool name. * @param string $title Human title. * @param string $description Description. * @param string $endpoint Endpoint URL. * @param array $parameters Parameters. * @return array */ private function tool_definition( $name, $title, $description, $endpoint, array $parameters ) { return array( 'name' => sanitize_key( $name ), 'title' => $title, 'description' => $description, 'endpoint' => esc_url_raw( $endpoint ), 'method' => 'GET', 'parameters' => $parameters, 'readOnlyHint' => true, 'untrustedContentHint' => true, 'requiresAuth' => false, 'changesState' => false, ); } /** * Format multiple posts. * * @param array $posts Posts. * @return array> */ private function format_posts( array $posts ) { $results = array(); foreach ( $posts as $post ) { if ( $post instanceof WP_Post && $this->is_public_post_allowed( $post ) ) { $results[] = $this->format_post( $post, false ); } } return $results; } /** * Format one public post. * * @param WP_Post $post Post. * @param bool $include_content Include longer text. * @return array */ private function format_post( WP_Post $post, $include_content ) { $summary = $this->get_summary( $post ); $data = array( 'id' => absint( $post->ID ), 'type' => get_post_type( $post ), 'title' => get_the_title( $post ), 'url' => get_permalink( $post ), 'date' => get_the_date( 'c', $post ), 'modified' => get_the_modified_date( 'c', $post ), 'summary' => $summary, 'content_tool' => rest_url( self::REST_NAMESPACE . '/content/' . absint( $post->ID ) ), ); if ( $this->integrations && $this->settings->get( 'integrations_enabled', 1 ) && $this->settings->get( 'integration_expose_seo_metadata_in_tools', 1 ) ) { $metadata = $this->integrations->get_post_metadata( $post->ID ); if ( ! empty( $metadata ) ) { $data['seo'] = array( 'source' => isset( $metadata['source'] ) ? sanitize_key( $metadata['source'] ) : '', 'title' => isset( $metadata['title'] ) ? $metadata['title'] : '', 'description' => isset( $metadata['description'] ) ? $metadata['description'] : '', 'canonical' => isset( $metadata['canonical'] ) ? esc_url_raw( $metadata['canonical'] ) : '', 'robots' => isset( $metadata['robots'] ) ? sanitize_text_field( $metadata['robots'] ) : '', 'focus_keyphrase' => isset( $metadata['focus_keyphrase'] ) ? sanitize_text_field( $metadata['focus_keyphrase'] ) : '', 'og_image' => isset( $metadata['og_image'] ) ? esc_url_raw( $metadata['og_image'] ) : '', ); } } $terms = $this->post_terms( $post ); if ( ! empty( $terms ) ) { $data['terms'] = $terms; } if ( $include_content ) { $data['text'] = $this->short_text( $post->post_content, 380 ); } return $data; } /** * Return public terms attached to a post. * * @param WP_Post $post Post. * @return array> */ private function post_terms( WP_Post $post ) { $taxonomies = get_object_taxonomies( get_post_type( $post ), 'names' ); $allowed = (array) $this->settings->get( 'include_taxonomies', array() ); $terms_out = array(); foreach ( $taxonomies as $taxonomy ) { if ( ! in_array( $taxonomy, $allowed, true ) ) { continue; } $terms = get_the_terms( $post, $taxonomy ); if ( is_wp_error( $terms ) || ! is_array( $terms ) ) { continue; } foreach ( $terms as $term ) { $terms_out[] = array( 'taxonomy' => sanitize_key( $taxonomy ), 'name' => $term->name, 'slug' => $term->slug, ); } } return $terms_out; } /** * Return selected public post types. * * @return array */ private function selected_post_types() { $post_types = (array) $this->settings->get( 'include_post_types', array() ); $public = $this->settings->get_public_post_type_names(); $clean = array(); foreach ( $post_types as $post_type ) { $post_type = sanitize_key( $post_type ); if ( in_array( $post_type, $public, true ) ) { $clean[] = $post_type; } } return ! empty( $clean ) ? array_values( array_unique( $clean ) ) : array( 'post', 'page' ); } /** * Validate requested post type. * * @param mixed $post_type Raw post type. * @return string */ private function requested_post_type( $post_type ) { $post_type = sanitize_key( (string) $post_type ); return in_array( $post_type, $this->selected_post_types(), true ) ? $post_type : ''; } /** * Bound per-page value. * * @param mixed $value Raw value. * @return int */ private function bounded_per_page( $value ) { $value = absint( $value ); if ( $value < 1 ) { return 10; } return min( 20, $value ); } /** * Check whether a post is safe to expose publicly through tools. * * @param WP_Post $post Post. * @return bool */ private function is_public_post_allowed( WP_Post $post ) { if ( 'publish' !== $post->post_status ) { return false; } if ( '' !== (string) $post->post_password ) { return false; } if ( ! in_array( get_post_type( $post ), $this->selected_post_types(), true ) ) { return false; } if ( $this->llms->should_skip_post( $post ) ) { return false; } return true; } /** * Return text summary for a post. * * @param WP_Post $post Post. * @return string */ private function get_summary( WP_Post $post ) { $excerpt = has_excerpt( $post ) ? get_the_excerpt( $post ) : ''; $text = '' !== trim( $excerpt ) ? $excerpt : $post->post_content; return $this->short_text( $text, 42 ); } /** * Clean and trim text. * * @param string $text Text. * @param int $words Word limit. * @return string */ private function short_text( $text, $words ) { $text = strip_shortcodes( (string) $text ); $text = wp_strip_all_tags( $text, true ); $text = preg_replace( '/\s+/', ' ', $text ); $text = trim( (string) $text ); return wp_trim_words( $text, absint( $words ), '…' ); } /** * Return disabled response. * * @return WP_REST_Response */ private function disabled_response() { return new WP_REST_Response( array( 'enabled' => false, 'message' => __( 'This public read-only agent tool is disabled.', 'agizoai-agentic-browsing-optimizer' ), ), 403 ); } /** * Return security notes. * * @return array */ private function security_notes() { return array( __( 'Only public read-only tools are exposed in v1.0.0.', 'agizoai-agentic-browsing-optimizer' ), __( 'No private, draft, password-protected or admin-only data is intentionally exposed.', 'agizoai-agentic-browsing-optimizer' ), __( 'Tools do not submit forms, create content, update orders, change settings or process payments.', 'agizoai-agentic-browsing-optimizer' ), __( 'Returned content may include public user-generated content and should be treated as untrusted by agents.', 'agizoai-agentic-browsing-optimizer' ), ); } /** * Return safe tool prefix. * * @return string */ private function tool_prefix() { $prefix = (string) $this->settings->get( 'webmcp_tool_prefix', 'site' ); $prefix = preg_replace( '/[^a-z0-9_]+/', '_', strtolower( $prefix ) ); $prefix = trim( (string) $prefix, '_' ); return '' !== $prefix ? $prefix : 'site'; } }