true` tools are rejected * with -32001 until an authenticated transport ships (v2). */ class Mcp_Tools { /** * Converter for get_page / get_product. * * @var Markdown_Converter */ private $converter; /** * Constructor. */ public function __construct() { $this->converter = new Markdown_Converter(); } /** * Full tool definitions (filterable). * * @return array[] */ public function get_tools() { $tools = array( array( 'name' => 'get_site_info', 'description' => 'Get an overview of this WordPress site: name, description, URL, language, content counts, taxonomies and agent-layer capabilities.', 'inputSchema' => array( 'type' => 'object', 'properties' => new \stdClass(), ), 'requires_auth' => false, 'callback' => array( $this, 'tool_get_site_info' ), ), array( 'name' => 'search_content', 'description' => 'Full-text search across the site\'s published content. Returns matching pages/posts with markdown URLs.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'query' => array( 'type' => 'string', 'description' => 'Search query.', ), 'post_type' => array( 'type' => 'string', 'description' => 'Restrict to one public post type (e.g. post, page, product).', ), 'limit' => array( 'type' => 'integer', 'description' => 'Results per page (default 10, max 20).', ), 'page' => array( 'type' => 'integer', 'description' => 'Result page, 1-based.', ), ), 'required' => array( 'query' ), ), 'requires_auth' => false, 'callback' => array( $this, 'tool_search_content' ), ), array( 'name' => 'get_page', 'description' => 'Fetch a single page/post as a markdown document (with frontmatter). Identify it by exactly one of: id, url, or slug.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'id' => array( 'type' => 'integer', 'description' => 'WordPress post ID.', ), 'url' => array( 'type' => 'string', 'description' => 'Full permalink URL.', ), 'slug' => array( 'type' => 'string', 'description' => 'Post/page slug or hierarchical path (e.g. "about/team").', ), ), ), 'requires_auth' => false, 'callback' => array( $this, 'tool_get_page' ), ), array( 'name' => 'list_recent_posts', 'description' => 'List the most recent published entries of a post type (default: post).', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'post_type' => array( 'type' => 'string', 'description' => 'Public post type (default "post").', ), 'limit' => array( 'type' => 'integer', 'description' => 'Results per page (default 10, max 20).', ), 'page' => array( 'type' => 'integer', 'description' => 'Result page, 1-based.', ), ), ), 'requires_auth' => false, 'callback' => array( $this, 'tool_list_recent_posts' ), ), array( 'name' => 'list_categories', 'description' => 'List the terms of a public taxonomy (default: category) with archive URLs and post counts.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'taxonomy' => array( 'type' => 'string', 'description' => 'Public taxonomy name (default "category").', ), ), ), 'requires_auth' => false, 'callback' => array( $this, 'tool_list_categories' ), ), ); if ( function_exists( 'wc_get_product' ) ) { $tools[] = array( 'name' => 'search_products', 'description' => 'Search the WooCommerce product catalog. Returns products with price, currency and stock status.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'query' => array( 'type' => 'string', 'description' => 'Search query.', ), 'limit' => array( 'type' => 'integer', 'description' => 'Max results (default 10, max 20).', ), ), 'required' => array( 'query' ), ), 'requires_auth' => false, 'callback' => array( $this, 'tool_search_products' ), ); $tools[] = array( 'name' => 'get_product', 'description' => 'Fetch a single WooCommerce product as markdown plus structured fields. Identify it by exactly one of: id or slug.', 'inputSchema' => array( 'type' => 'object', 'properties' => array( 'id' => array( 'type' => 'integer', 'description' => 'Product ID.', ), 'slug' => array( 'type' => 'string', 'description' => 'Product slug.', ), ), ), 'requires_auth' => false, 'callback' => array( $this, 'tool_get_product' ), ); } /** * Filter the MCP tool registry. * * @param array[] $tools Tool definitions {name, description, inputSchema, requires_auth, callback}. */ return apply_filters( 'ael42a_mcp_tools', $tools ); } /** * Tool definitions shaped for the MCP tools/list response (no callbacks). * * @return array[] */ public function describe_tools() { $out = array(); foreach ( $this->get_tools() as $tool ) { $out[] = array( 'name' => $tool['name'], 'description' => $tool['description'], 'inputSchema' => $tool['inputSchema'], ); } return $out; } /** * Find a tool by name. * * @param string $name Tool name. * @return array|null */ public function get_tool( $name ) { foreach ( $this->get_tools() as $tool ) { if ( $tool['name'] === $name ) { return $tool; } } return null; } // ---------------------------------------------------------------- tools. /** * get_site_info. * * @param array $args Arguments (unused). * @return array */ public function tool_get_site_info( $args ) { unset( $args ); $post_types = array(); foreach ( get_post_types( array( 'public' => true ), 'objects' ) as $type ) { if ( 'attachment' === $type->name ) { continue; } $counts = wp_count_posts( $type->name ); $post_types[ $type->name ] = isset( $counts->publish ) ? (int) $counts->publish : 0; } $taxonomies = array_values( get_taxonomies( array( 'public' => true ) ) ); return array( 'name' => get_bloginfo( 'name' ), 'description' => get_bloginfo( 'description' ), 'url' => home_url( '/' ), 'language' => get_bloginfo( 'language' ), 'post_types' => $post_types, 'taxonomies' => $taxonomies, 'woocommerce' => function_exists( 'wc_get_product' ), 'ael' => array( 'markdown' => true, 'llms_txt' => true, 'mcp_version' => AEL42A_VERSION, ), ); } /** * search_content. * * @param array $args {query, post_type?, limit?, page?}. * @return array|\WP_Error */ public function tool_search_content( $args ) { $query = isset( $args['query'] ) ? sanitize_text_field( (string) $args['query'] ) : ''; if ( '' === trim( $query ) ) { return new \WP_Error( 'invalid_args', 'search_content requires a non-empty "query" argument.' ); } $post_type = $this->sanitize_post_type( isset( $args['post_type'] ) ? $args['post_type'] : null ); if ( is_wp_error( $post_type ) ) { return $post_type; } $limit = $this->clamp_int( isset( $args['limit'] ) ? $args['limit'] : 10, 1, 20 ); $page = $this->clamp_int( isset( $args['page'] ) ? $args['page'] : 1, 1, 1000 ); $wp_query = new \WP_Query( array( 's' => $query, 'post_type' => null !== $post_type ? $post_type : array_values( get_post_types( array( 'public' => true ) ) ), 'post_status' => 'publish', 'posts_per_page' => $limit, 'paged' => $page, 'no_found_rows' => true, 'ignore_sticky_posts' => true, ) ); return array( 'query' => $query, 'page' => $page, 'results' => array_map( array( $this, 'post_row' ), $wp_query->posts ), ); } /** * get_page. * * @param array $args {id? | url? | slug?} - exactly one. * @return string|\WP_Error Markdown document. */ public function tool_get_page( $args ) { $provided = 0; foreach ( array( 'id', 'url', 'slug' ) as $key ) { if ( isset( $args[ $key ] ) && '' !== trim( (string) $args[ $key ] ) ) { $provided++; } } if ( 1 !== $provided ) { return new \WP_Error( 'invalid_args', 'get_page requires exactly one of: id, url, slug.' ); } $post = null; if ( ! empty( $args['id'] ) ) { $post = get_post( (int) $args['id'] ); } elseif ( ! empty( $args['url'] ) ) { $post_id = url_to_postid( esc_url_raw( (string) $args['url'] ) ); $post = $post_id > 0 ? get_post( $post_id ) : null; } elseif ( ! empty( $args['slug'] ) ) { $slug = trim( (string) $args['slug'], "/ \t\n\r" ); foreach ( get_post_types( array( 'public' => true ) ) as $type ) { $found = get_page_by_path( $slug, OBJECT, $type ); if ( $found instanceof \WP_Post ) { $post = $found; break; } } } if ( ! $post instanceof \WP_Post || 'publish' !== $post->post_status || ! is_post_publicly_viewable( $post ) || post_password_required( $post ) ) { return new \WP_Error( 'not_found', 'No published public page matches the given identifier.' ); } return $this->converter->document( $post ); } /** * list_recent_posts. * * @param array $args {post_type?, limit?, page?}. * @return array|\WP_Error */ public function tool_list_recent_posts( $args ) { $post_type = $this->sanitize_post_type( isset( $args['post_type'] ) ? $args['post_type'] : 'post' ); if ( is_wp_error( $post_type ) ) { return $post_type; } if ( null === $post_type ) { $post_type = 'post'; } $limit = $this->clamp_int( isset( $args['limit'] ) ? $args['limit'] : 10, 1, 20 ); $page = $this->clamp_int( isset( $args['page'] ) ? $args['page'] : 1, 1, 1000 ); $wp_query = new \WP_Query( array( 'post_type' => $post_type, 'post_status' => 'publish', 'posts_per_page' => $limit, 'paged' => $page, 'orderby' => 'date', 'order' => 'DESC', 'no_found_rows' => true, 'ignore_sticky_posts' => true, ) ); return array( 'post_type' => $post_type, 'page' => $page, 'results' => array_map( array( $this, 'post_row' ), $wp_query->posts ), ); } /** * list_categories. * * @param array $args {taxonomy?}. * @return array|\WP_Error */ public function tool_list_categories( $args ) { $taxonomy = isset( $args['taxonomy'] ) ? sanitize_key( (string) $args['taxonomy'] ) : 'category'; $public = get_taxonomies( array( 'public' => true ) ); if ( ! in_array( $taxonomy, $public, true ) ) { return new \WP_Error( 'invalid_args', sprintf( 'Taxonomy "%s" does not exist or is not public.', $taxonomy ) ); } $terms = get_terms( array( 'taxonomy' => $taxonomy, 'hide_empty' => true, 'number' => 100, ) ); if ( is_wp_error( $terms ) ) { return $terms; } $rows = array(); foreach ( $terms as $term ) { $url = get_term_link( $term ); $rows[] = array( 'name' => $term->name, 'slug' => $term->slug, 'url' => is_wp_error( $url ) ? '' : $url, 'count' => (int) $term->count, ); } return array( 'taxonomy' => $taxonomy, 'terms' => $rows, ); } /** * search_products (WooCommerce only). * * @param array $args {query, limit?}. * @return array|\WP_Error */ public function tool_search_products( $args ) { if ( ! function_exists( 'wc_get_product' ) ) { return new \WP_Error( 'unavailable', 'WooCommerce is not active on this site.' ); } $query = isset( $args['query'] ) ? sanitize_text_field( (string) $args['query'] ) : ''; if ( '' === trim( $query ) ) { return new \WP_Error( 'invalid_args', 'search_products requires a non-empty "query" argument.' ); } $limit = $this->clamp_int( isset( $args['limit'] ) ? $args['limit'] : 10, 1, 20 ); $wp_query = new \WP_Query( array( 's' => $query, 'post_type' => 'product', 'post_status' => 'publish', 'posts_per_page' => $limit, 'no_found_rows' => true, ) ); $rows = array(); foreach ( $wp_query->posts as $post ) { $product = wc_get_product( $post->ID ); if ( ! $product ) { continue; } $rows[] = array( 'id' => $post->ID, 'name' => $product->get_name(), 'url' => get_permalink( $post ), 'markdown_url' => Markdown_Controller::markdown_url( $post ), 'price' => (string) $product->get_price(), 'currency' => get_woocommerce_currency(), 'in_stock' => $product->is_in_stock(), 'short_description' => wp_strip_all_tags( $product->get_short_description() ), ); } return array( 'query' => $query, 'results' => $rows, ); } /** * get_product (WooCommerce only). * * @param array $args {id? | slug?} - exactly one. * @return array|\WP_Error */ public function tool_get_product( $args ) { if ( ! function_exists( 'wc_get_product' ) ) { return new \WP_Error( 'unavailable', 'WooCommerce is not active on this site.' ); } $has_id = isset( $args['id'] ) && (int) $args['id'] > 0; $has_slug = isset( $args['slug'] ) && '' !== trim( (string) $args['slug'] ); if ( $has_id === $has_slug ) { return new \WP_Error( 'invalid_args', 'get_product requires exactly one of: id, slug.' ); } $post = null; if ( $has_id ) { $post = get_post( (int) $args['id'] ); } else { $post = get_page_by_path( sanitize_title( (string) $args['slug'] ), OBJECT, 'product' ); } if ( ! $post instanceof \WP_Post || 'product' !== $post->post_type || 'publish' !== $post->post_status ) { return new \WP_Error( 'not_found', 'No published product matches the given identifier.' ); } $product = wc_get_product( $post->ID ); if ( ! $product ) { return new \WP_Error( 'not_found', 'No published product matches the given identifier.' ); } $categories = array(); $terms = get_the_terms( $post->ID, 'product_cat' ); if ( is_array( $terms ) ) { foreach ( $terms as $term ) { $categories[] = $term->name; } } return array( 'id' => $post->ID, 'name' => $product->get_name(), 'url' => get_permalink( $post ), 'markdown_url' => Markdown_Controller::markdown_url( $post ), 'price' => (string) $product->get_price(), 'currency' => get_woocommerce_currency(), 'sku' => $product->get_sku(), 'in_stock' => $product->is_in_stock(), 'stock_status' => $product->get_stock_status(), 'categories' => $categories, 'markdown' => $this->converter->document( $post ), ); } // -------------------------------------------------------------- helpers. /** * Shared result row for a post. * * @param \WP_Post $post Post. * @return array */ private function post_row( $post ) { return array( 'id' => $post->ID, 'title' => get_the_title( $post ), 'url' => get_permalink( $post ), 'markdown_url' => Markdown_Controller::markdown_url( $post ), 'excerpt' => wp_trim_words( wp_strip_all_tags( strip_shortcodes( '' !== $post->post_excerpt ? $post->post_excerpt : $post->post_content ) ), 30, '...' ), 'type' => $post->post_type, 'date' => get_post_time( 'c', true, $post ), ); } /** * Validate an optional post type argument against public post types. * * @param mixed $value Raw value. * @return string|null|\WP_Error Sanitized type, null when absent, or error. */ private function sanitize_post_type( $value ) { if ( null === $value || '' === $value ) { return null; } $type = sanitize_key( (string) $value ); $public = get_post_types( array( 'public' => true ) ); if ( ! in_array( $type, $public, true ) ) { return new \WP_Error( 'invalid_args', sprintf( 'Post type "%s" does not exist or is not public.', $type ) ); } return $type; } /** * Clamp an integer argument. * * @param mixed $value Raw value. * @param int $min Minimum. * @param int $max Maximum. * @return int */ private function clamp_int( $value, $min, $max ) { return max( $min, min( $max, (int) $value ) ); } }