AI Agents (MCP) - update * a page's title/description, add a redirect, or queue a rank tracker * keyword. * * Protocol: JSON-RPC 2.0 over a single POST endpoint * (wp-json/citedseo/v1/mcp), matching the MCP "Streamable HTTP" * transport's simple single-response mode - no SSE, no session * negotiation. Every request carries its own Bearer token, which keeps * this implementation small enough to audit at a glance instead of * reimplementing OAuth from scratch. That is a deliberate v1 trade-off: * a single long-lived key is simpler and just as secure as OAuth for a * single site-to-agent connection, but it means the key must be treated * like a password (regenerate it if it ever leaks). * * On WordPress 6.9+, abilities are ALSO registered with WordPress core's * own Abilities API (wp_register_ability()) when it's present, so a * site that also runs the WordPress-maintained MCP Adapter plugin can * reach the same abilities through that standard path too. Our own * endpoint below is what makes this work out of the box, with no extra * plugin required. */ class AWBSEO_MCP { const OPT_KEY = 'awbseo_mcp_key'; const OPT_ALLOW_WRITE = 'awbseo_mcp_allow_write'; const NAMESPACE_ = 'citedseo/v1'; const ROUTE = '/mcp'; public static function init() { add_action( 'rest_api_init', [ __CLASS__, 'register_route' ] ); add_action( 'wp_abilities_api_init', [ __CLASS__, 'register_core_abilities' ] ); add_action( 'wp_ajax_awbseo_mcp_generate_key', [ __CLASS__, 'ajax_generate_key' ] ); add_action( 'wp_ajax_awbseo_mcp_save_settings', [ __CLASS__, 'ajax_save_settings' ] ); } /* ── Key + settings ──────────────────────────────────────────────────── */ public static function get_key() { $key = get_option( self::OPT_KEY, '' ); if ( ! $key ) { $key = self::make_key(); update_option( self::OPT_KEY, $key, false ); } return $key; } private static function make_key() { return 'cseo_' . bin2hex( random_bytes( 24 ) ); } public static function allow_write() { return (bool) get_option( self::OPT_ALLOW_WRITE, false ); } public static function endpoint_url() { return rest_url( self::NAMESPACE_ . self::ROUTE ); } public static function ajax_generate_key() { check_ajax_referer( 'awbseo_admin', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( 'Forbidden', 403 ); $key = self::make_key(); update_option( self::OPT_KEY, $key, false ); wp_send_json_success( [ 'key' => $key, 'endpoint' => self::endpoint_url() ] ); } public static function ajax_save_settings() { check_ajax_referer( 'awbseo_admin', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) wp_send_json_error( 'Forbidden', 403 ); update_option( self::OPT_ALLOW_WRITE, ! empty( $_POST['allow_write'] ), false ); wp_send_json_success( [ 'allow_write' => self::allow_write() ] ); } /* ── REST transport ──────────────────────────────────────────────────── */ public static function register_route() { register_rest_route( self::NAMESPACE_, self::ROUTE, [ 'methods' => 'POST', 'callback' => [ __CLASS__, 'handle_request' ], 'permission_callback' => [ __CLASS__, 'check_auth' ], ] ); } public static function check_auth( WP_REST_Request $request ) { $auth = $request->get_header( 'authorization' ); if ( ! $auth || stripos( $auth, 'Bearer ' ) !== 0 ) { return false; } $token = trim( substr( $auth, 7 ) ); $key = self::get_key(); return $token && $key && hash_equals( $key, $token ); } public static function handle_request( WP_REST_Request $request ) { $body = $request->get_json_params(); if ( ! is_array( $body ) ) { return self::rpc_error( null, -32700, 'Parse error' ); } $id = $body['id'] ?? null; $method = $body['method'] ?? ''; $params = is_array( $body['params'] ?? null ) ? $body['params'] : []; switch ( $method ) { case 'initialize': return self::rpc_result( $id, [ 'protocolVersion' => '2024-11-05', 'capabilities' => [ 'tools' => new stdClass() ], 'serverInfo' => [ 'name' => 'CitedSEO', 'version' => AWBSEO_VERSION ], ] ); case 'notifications/initialized': // Client notification - MCP expects no response body. return new WP_REST_Response( null, 204 ); case 'tools/list': return self::rpc_result( $id, [ 'tools' => self::tool_definitions() ] ); case 'tools/call': return self::call_tool( $id, $params ); default: return self::rpc_error( $id, -32601, 'Method not found: ' . $method ); } } private static function rpc_result( $id, $result ) { return new WP_REST_Response( [ 'jsonrpc' => '2.0', 'id' => $id, 'result' => $result ], 200 ); } private static function rpc_error( $id, $code, $message ) { return new WP_REST_Response( [ 'jsonrpc' => '2.0', 'id' => $id, 'error' => [ 'code' => $code, 'message' => $message ] ], 200 ); } /* ── Ability registry ────────────────────────────────────────────────── */ /** * Every ability this site can run over MCP - free plugin abilities * first, then anything Pro (or another add-on) contributes via the * citedseo_mcp_abilities filter, the same extension pattern the rest * of the plugin already uses for category tool cards. */ public static function abilities() { $free = [ 'get-seo-health' => [ 'description' => 'Site-wide SEO health snapshot: how many of the most recent 500 published posts/pages are missing a title, description, or focus keyword, and how many broken links are currently tracked.', 'input_schema' => [ 'type' => 'object', 'properties' => new stdClass() ], 'write' => false, 'callback' => [ __CLASS__, 'ability_get_seo_health' ], ], 'get-post-seo' => [ 'description' => 'Read the SEO title, meta description, focus keyword, robots directives, canonical URL, and schema type currently set for one post or page.', 'input_schema' => [ 'type' => 'object', 'properties' => [ 'post_id' => [ 'type' => 'integer', 'description' => 'The WordPress post/page ID.' ] ], 'required' => [ 'post_id' ], ], 'write' => false, 'callback' => [ __CLASS__, 'ability_get_post_seo' ], ], 'update-post-seo' => [ 'description' => 'Update the SEO title, meta description, and/or focus keyword for one post or page. Only the fields provided are changed.', 'input_schema' => [ 'type' => 'object', 'properties' => [ 'post_id' => [ 'type' => 'integer' ], 'title' => [ 'type' => 'string' ], 'description' => [ 'type' => 'string' ], 'focus_keyword' => [ 'type' => 'string' ], ], 'required' => [ 'post_id' ], ], 'write' => true, 'callback' => [ __CLASS__, 'ability_update_post_seo' ], ], 'run-onpage-analysis' => [ 'description' => 'Run the on-page SEO analyzer against one post/page right now and return every check it flags (title length, keyword usage, image alt text, internal links, and more).', 'input_schema' => [ 'type' => 'object', 'properties' => [ 'post_id' => [ 'type' => 'integer' ] ], 'required' => [ 'post_id' ], ], 'write' => false, 'callback' => [ __CLASS__, 'ability_run_onpage_analysis' ], ], 'list-broken-links' => [ 'description' => 'List currently-tracked broken links (internal and external) found on the site, most recent scan first.', 'input_schema' => [ 'type' => 'object', 'properties' => [ 'limit' => [ 'type' => 'integer', 'description' => 'Max rows to return, default 50.' ] ], ], 'write' => false, 'callback' => [ __CLASS__, 'ability_list_broken_links' ], ], 'get-content-decay' => [ 'description' => 'List posts/pages losing organic traffic over time (content decay), ranked by how much they have dropped.', 'input_schema' => [ 'type' => 'object', 'properties' => [ 'limit' => [ 'type' => 'integer' ] ], ], 'write' => false, 'callback' => [ __CLASS__, 'ability_get_content_decay' ], ], 'get-keyword-cannibalization' => [ 'description' => 'List groups of pages competing against each other for the same search queries (keyword cannibalization).', 'input_schema' => [ 'type' => 'object', 'properties' => new stdClass() ], 'write' => false, 'callback' => [ __CLASS__, 'ability_get_cannibalization' ], ], 'get-search-console-data' => [ 'description' => 'Top Search Console queries and pages for the connected property over a given period.', 'input_schema' => [ 'type' => 'object', 'properties' => [ 'days' => [ 'type' => 'integer', 'description' => 'Lookback window in days, default 28.' ], 'limit' => [ 'type' => 'integer', 'description' => 'Max rows per list, default 10.' ], ], ], 'write' => false, 'callback' => [ __CLASS__, 'ability_get_search_console_data' ], ], 'get-ai-search-visibility' => [ 'description' => 'Sessions arriving from AI engines (ChatGPT, Perplexity, Gemini, Copilot, Claude, and others), via the connected Google Analytics property.', 'input_schema' => [ 'type' => 'object', 'properties' => [ 'days' => [ 'type' => 'integer', 'description' => 'Lookback window in days, default 28.' ] ], ], 'write' => false, 'callback' => [ __CLASS__, 'ability_get_ai_visibility' ], ], 'list-rank-tracker-keywords' => [ 'description' => 'List every keyword in the Rank Tracker with its most recent tracked position.', 'input_schema' => [ 'type' => 'object', 'properties' => new stdClass() ], 'write' => false, 'callback' => [ __CLASS__, 'ability_list_rank_keywords' ], ], 'add-rank-tracker-keyword' => [ 'description' => 'Add a keyword to the Rank Tracker so its position starts being checked.', 'input_schema' => [ 'type' => 'object', 'properties' => [ 'keyword' => [ 'type' => 'string' ], 'target_url' => [ 'type' => 'string' ], 'region' => [ 'type' => 'string' ], ], 'required' => [ 'keyword' ], ], 'write' => true, 'callback' => [ __CLASS__, 'ability_add_rank_keyword' ], ], 'remove-rank-tracker-keyword' => [ 'description' => 'Stop tracking a keyword in the Rank Tracker.', 'input_schema' => [ 'type' => 'object', 'properties' => [ 'keyword' => [ 'type' => 'string' ] ], 'required' => [ 'keyword' ], ], 'write' => true, 'callback' => [ __CLASS__, 'ability_remove_rank_keyword' ], ], 'list-redirects' => [ 'description' => 'List configured 301/302 redirects.', 'input_schema' => [ 'type' => 'object', 'properties' => [ 'limit' => [ 'type' => 'integer' ] ], ], 'write' => false, 'callback' => [ __CLASS__, 'ability_list_redirects' ], ], 'create-redirect' => [ 'description' => 'Create a redirect from one URL path to another.', 'input_schema' => [ 'type' => 'object', 'properties' => [ 'source' => [ 'type' => 'string', 'description' => 'The old URL path to redirect from, e.g. /old-page/.' ], 'target' => [ 'type' => 'string', 'description' => 'The destination URL or path.' ], 'type' => [ 'type' => 'integer', 'description' => '301 (permanent) or 302 (temporary), default 301.' ], ], 'required' => [ 'source', 'target' ], ], 'write' => true, 'callback' => [ __CLASS__, 'ability_create_redirect' ], ], 'submit-url-for-indexing' => [ 'description' => 'Submit one or more URLs to search engines for instant indexing (IndexNow).', 'input_schema' => [ 'type' => 'object', 'properties' => [ 'urls' => [ 'type' => 'array', 'items' => [ 'type' => 'string' ] ] ], 'required' => [ 'urls' ], ], 'write' => true, 'callback' => [ __CLASS__, 'ability_submit_indexing' ], ], ]; return apply_filters( 'citedseo_mcp_abilities', $free ); } private static function tool_definitions() { $tools = []; foreach ( self::abilities() as $name => $ability ) { if ( ! empty( $ability['write'] ) && ! self::allow_write() ) { continue; // Write actions stay hidden from tools/list until the site owner opts in. } $tools[] = [ 'name' => $name, 'description' => $ability['description'], 'inputSchema' => $ability['input_schema'], ]; } return $tools; } private static function call_tool( $id, $params ) { $name = $params['name'] ?? ''; $arguments = is_array( $params['arguments'] ?? null ) ? $params['arguments'] : []; $abilities = self::abilities(); if ( ! isset( $abilities[ $name ] ) ) { return self::rpc_error( $id, -32602, 'Unknown tool: ' . $name ); } $ability = $abilities[ $name ]; if ( ! empty( $ability['write'] ) && ! self::allow_write() ) { return self::rpc_result( $id, [ 'isError' => true, 'content' => [ [ 'type' => 'text', 'text' => "Write access is turned off for this site's AI Agents connection. Turn it on under CitedSEO -> AI Agents (MCP) to allow " . $name . '.', ] ], ] ); } try { $result = call_user_func( $ability['callback'], $arguments ); return self::rpc_result( $id, [ 'content' => [ [ 'type' => 'text', 'text' => wp_json_encode( $result ) ] ], ] ); } catch ( Throwable $e ) { return self::rpc_result( $id, [ 'isError' => true, 'content' => [ [ 'type' => 'text', 'text' => 'Error: ' . $e->getMessage() ] ], ] ); } } /* ── Core Abilities API bridge (WordPress 6.9+) ──────────────────────── */ public static function register_core_abilities() { if ( ! function_exists( 'wp_register_ability' ) ) { return; } foreach ( self::abilities() as $name => $ability ) { wp_register_ability( 'citedseo/' . $name, [ 'label' => $ability['description'], 'description' => $ability['description'], 'input_schema' => $ability['input_schema'], 'execute_callback' => $ability['callback'], 'permission_callback' => function () use ( $ability ) { if ( ! current_user_can( 'manage_options' ) ) return false; return empty( $ability['write'] ) || self::allow_write(); }, ] ); } } /* ── Ability callbacks ────────────────────────────────────────────────── */ public static function ability_get_seo_health( $args ) { $posts = get_posts( [ 'post_type' => 'any', 'post_status' => 'publish', 'numberposts' => 500, 'orderby' => 'date', 'order' => 'DESC', 'fields' => 'ids', ] ); $no_title = $no_desc = $no_kw = 0; foreach ( $posts as $post_id ) { if ( ! AWBSEO_Meta_Manager::get( $post_id, 'title' ) ) $no_title++; if ( ! AWBSEO_Meta_Manager::get( $post_id, 'description' ) ) $no_desc++; if ( ! AWBSEO_Meta_Manager::get( $post_id, 'focus_keyword' ) ) $no_kw++; } return [ 'posts_checked' => count( $posts ), 'missing_custom_title' => $no_title, 'missing_meta_description' => $no_desc, 'missing_focus_keyword' => $no_kw, 'broken_links_tracked' => count( AWBSEO_Broken_Links::get_broken( 500 ) ), ]; } public static function ability_get_post_seo( $args ) { $post_id = (int) ( $args['post_id'] ?? 0 ); if ( ! $post_id || ! get_post( $post_id ) ) { throw new Exception( 'No post found with that ID.' ); } $meta = AWBSEO_Meta_Manager::get_post_meta( $post_id ); return [ 'post_id' => $post_id, 'title' => AWBSEO_Meta_Manager::get_title( $post_id ), 'description' => AWBSEO_Meta_Manager::get_description( $post_id ), 'focus_keyword' => $meta['focus_keyword'], 'robots' => AWBSEO_Meta_Manager::get_robots( $post_id ), 'canonical' => $meta['canonical'], 'schema_type' => $meta['schema_type'], ]; } public static function ability_update_post_seo( $args ) { $post_id = (int) ( $args['post_id'] ?? 0 ); if ( ! $post_id || ! get_post( $post_id ) ) { throw new Exception( 'No post found with that ID.' ); } $updated = []; if ( isset( $args['title'] ) ) { $value = sanitize_text_field( $args['title'] ); update_post_meta( $post_id, AWBSEO_Meta_Manager::META_PREFIX . 'title', $value ); $updated['title'] = $value; } if ( isset( $args['description'] ) ) { $value = sanitize_textarea_field( $args['description'] ); update_post_meta( $post_id, AWBSEO_Meta_Manager::META_PREFIX . 'description', $value ); $updated['description'] = $value; } if ( isset( $args['focus_keyword'] ) ) { $value = sanitize_text_field( $args['focus_keyword'] ); update_post_meta( $post_id, AWBSEO_Meta_Manager::META_PREFIX . 'focus_keyword', $value ); $updated['focus_keyword'] = $value; } if ( ! $updated ) { throw new Exception( 'Provide at least one of title, description, or focus_keyword to update.' ); } $updated['post_id'] = $post_id; return $updated; } public static function ability_run_onpage_analysis( $args ) { $post_id = (int) ( $args['post_id'] ?? 0 ); if ( ! $post_id || ! get_post( $post_id ) ) { throw new Exception( 'No post found with that ID.' ); } return AWBSEO_On_Page_Analyzer::analyze( $post_id ); } public static function ability_list_broken_links( $args ) { $limit = (int) ( $args['limit'] ?? 50 ); return [ 'broken_links' => AWBSEO_Broken_Links::get_broken( $limit ?: 50 ) ]; } public static function ability_get_content_decay( $args ) { $limit = (int) ( $args['limit'] ?? 50 ); return [ 'decaying_posts' => AWBSEO_Content_Decay::get_decaying_posts( $limit ?: 50 ) ]; } public static function ability_get_cannibalization( $args ) { return [ 'groups' => AWBSEO_Keyword_Cannibalization::get_all_groups() ]; } public static function ability_get_search_console_data( $args ) { $days = (int) ( $args['days'] ?? 28 ); $limit = (int) ( $args['limit'] ?? 10 ); $gsc = AWBSEO_GSC::get_instance(); if ( ! $gsc->is_connected() ) { throw new Exception( 'Google Search Console is not connected on this site yet.' ); } return [ 'top_queries' => $gsc->get_top_queries( $days ?: 28, $limit ?: 10 ), 'top_pages' => $gsc->get_top_pages( $days ?: 28, $limit ?: 10 ), ]; } public static function ability_get_ai_visibility( $args ) { $days = (int) ( $args['days'] ?? 28 ); if ( ! class_exists( 'AWBSEO_GA4' ) ) { throw new Exception( 'Google Analytics is not available on this site.' ); } $ga4 = AWBSEO_GA4::get_instance(); if ( ! $ga4->is_connected() ) { throw new Exception( 'Google Analytics 4 is not connected on this site yet.' ); } return [ 'sessions_by_ai_engine' => $ga4->get_ai_sessions( $days ?: 28 ), 'total_ai_sessions' => $ga4->get_ai_total( $days ?: 28 ), ]; } public static function ability_list_rank_keywords( $args ) { return [ 'keywords' => AWBSEO_Rank_Tracker::get_keywords() ]; } public static function ability_add_rank_keyword( $args ) { $keyword = sanitize_text_field( $args['keyword'] ?? '' ); if ( ! $keyword ) { throw new Exception( 'A keyword is required.' ); } $target_url = esc_url_raw( $args['target_url'] ?? '' ); $region = sanitize_text_field( $args['region'] ?? '' ); AWBSEO_Rank_Tracker::add_keyword( $keyword, $target_url, $region ); return [ 'added' => $keyword ]; } public static function ability_remove_rank_keyword( $args ) { $keyword = sanitize_text_field( $args['keyword'] ?? '' ); if ( ! $keyword ) { throw new Exception( 'A keyword is required.' ); } AWBSEO_Rank_Tracker::remove_keyword( $keyword ); return [ 'removed' => $keyword ]; } public static function ability_list_redirects( $args ) { $limit = (int) ( $args['limit'] ?? 200 ); return [ 'redirects' => AWBSEO_Redirects::get_all( $limit ?: 200 ) ]; } public static function ability_create_redirect( $args ) { $source = sanitize_text_field( $args['source'] ?? '' ); $target = sanitize_text_field( $args['target'] ?? '' ); $type = (int) ( $args['type'] ?? 301 ); if ( ! $source || ! $target ) { throw new Exception( 'Both source and target are required.' ); } AWBSEO_Redirects::add( $source, $target, $type ?: 301 ); return [ 'created' => [ 'source' => $source, 'target' => $target, 'type' => $type ?: 301 ] ]; } public static function ability_submit_indexing( $args ) { $urls = array_filter( array_map( 'esc_url_raw', (array) ( $args['urls'] ?? [] ) ) ); if ( ! $urls ) { throw new Exception( 'At least one URL is required.' ); } return AWBSEO_Instant_Indexing::submit( array_values( $urls ) ); } }