File Manager
Viewing File: abilitiesregister.php
<?php
/*
* SITESEO
* https://siteseo.io
* (c) SITSEO Team
*/
namespace SiteSEO;
// Are we being accessed directly ?
if(!defined('ABSPATH')){
die('Hacking Attempt !');
}
/**
* Registers SiteSEO abilities with the WordPress 6.9+ Abilities API.
*
* Abilities are exposed at /wp-json/wp-abilities/v1/ when the Abilities API is
* present, and are surfaced as MCP tools when a compatible MCP adapter
* (e.g. wordpress/mcp-adapter) is installed.
*
* Each execute_callback reads or writes real SiteSEO data (post meta, options,
* robots.txt, sitemap state) following SiteSEO's own storage conventions.
*/
class AbilitiesRegister{
/**
* Register the SiteSEO ability categories.
*
* @return void
*/
static function register_categories(){
$categories = [
'siteseo-posts' => __('SiteSEO — Posts', 'siteseo'),
'siteseo-settings' => __('SiteSEO — Settings', 'siteseo'),
'siteseo-audit' => __('SiteSEO — Audit', 'siteseo'),
'siteseo-sitemap' => __('SiteSEO — Sitemap', 'siteseo'),
];
foreach($categories as $slug => $label){
wp_register_ability_category($slug, [
'label' => $label,
'description' => __('SEO management abilities provided by SiteSEO.', 'siteseo'),
]);
}
}
/**
* Register all SiteSEO abilities.
*
* @return void
*/
static function register_abilities(){
self::register_post_abilities();
self::register_settings_ability();
self::register_audit_abilities();
self::register_sitemap_abilities();
}
/**
* Shared meta block for read-only abilities.
*
* @return array
*/
protected static function readonly_meta(){
return [
'annotations' => ['readonly' => true],
'show_in_rest' => true,
'mcp' => ['public' => true],
];
}
/**
* Input schema for abilities that take no input.
*
* @return array
*/
protected static function no_input_schema(){
return [
'type' => 'object',
'additionalProperties' => false,
'default' => [],
];
}
// =========================================================================
// Permission callbacks
// =========================================================================
/**
* @return bool
*/
public static function can_edit_posts(){
return current_user_can('edit_posts');
}
/**
* @return bool
*/
public static function can_manage_options(){
return current_user_can('manage_options');
}
// =========================================================================
// Posts abilities
// =========================================================================
protected static function register_post_abilities(){
$post_snapshot_schema = [
'type' => 'object',
'properties' => [
'title' => ['type' => ['string', 'null']],
'description' => ['type' => ['string', 'null']],
'canonical_url' => ['type' => ['string', 'null']],
'focus_keyphrase' => ['type' => ['string', 'null']],
'seo_score' => ['type' => ['string', 'null']],
'robots' => [
'type' => 'object',
'properties' => [
'noindex' => ['type' => 'boolean'],
'nofollow' => ['type' => 'boolean'],
'noimageindex' => ['type' => 'boolean'],
'noarchive' => ['type' => 'boolean'],
'nosnippet' => ['type' => 'boolean'],
],
],
'social' => [
'type' => 'object',
'properties' => [
'og_title' => ['type' => ['string', 'null']],
'og_description' => ['type' => ['string', 'null']],
'og_image' => ['type' => ['string', 'null']],
'twitter_title' => ['type' => ['string', 'null']],
'twitter_description' => ['type' => ['string', 'null']],
'twitter_image' => ['type' => ['string', 'null']],
],
],
],
];
// siteseo-posts/seo-data-get
wp_register_ability('siteseo-posts/seo-data-get', [
'label' => __('Get Post SEO Data', 'siteseo'),
'description' => __('Returns the SEO snapshot for a post: title, meta description, focus keyphrase, robots flags, canonical URL and social meta.', 'siteseo'),
'category' => 'siteseo-posts',
'input_schema' => [
'type' => 'object',
'properties' => [
'postId' => [
'type' => 'integer',
'description' => __('The post ID.', 'siteseo'),
],
],
'required' => ['postId'],
'additionalProperties' => false,
],
'output_schema' => $post_snapshot_schema,
'execute_callback' => '\SiteSEO\AbilitiesRegister::get_post_seo_data',
'permission_callback' => '\SiteSEO\AbilitiesRegister::can_edit_posts',
'meta' => self::readonly_meta(),
]);
// siteseo-posts/seo-data-update
wp_register_ability('siteseo-posts/seo-data-update', [
'label' => __('Update Post SEO Data', 'siteseo'),
'description' => __('Updates SEO fields for a post: title, description, focus keyphrase, robots flags, canonical URL and social meta. Only the fields provided in input are changed; others are preserved.', 'siteseo'),
'category' => 'siteseo-posts',
'input_schema' => [
'type' => 'object',
'properties' => [
'postId' => [
'type' => 'integer',
'description' => __('The post ID.', 'siteseo'),
],
'title' => ['type' => ['string', 'null']],
'description' => ['type' => ['string', 'null']],
'canonical_url' => ['type' => ['string', 'null']],
'focus_keyphrase' => ['type' => ['string', 'null']],
'robots' => [
'type' => 'object',
'properties' => [
'noindex' => ['type' => 'boolean'],
'nofollow' => ['type' => 'boolean'],
'noimageindex' => ['type' => 'boolean'],
'noarchive' => ['type' => 'boolean'],
'nosnippet' => ['type' => 'boolean'],
],
'additionalProperties' => false,
],
'social' => [
'type' => 'object',
'properties' => [
'og_title' => ['type' => ['string', 'null']],
'og_description' => ['type' => ['string', 'null']],
'og_image' => ['type' => ['string', 'null']],
'twitter_title' => ['type' => ['string', 'null']],
'twitter_description' => ['type' => ['string', 'null']],
'twitter_image' => ['type' => ['string', 'null']],
],
'additionalProperties' => false,
],
],
'required' => ['postId'],
'additionalProperties' => false,
],
'output_schema' => [
'type' => 'object',
'properties' => [
'updated' => ['type' => 'boolean'],
'post' => $post_snapshot_schema,
],
],
'execute_callback' => '\SiteSEO\AbilitiesRegister::update_post_seo_data',
'permission_callback' => '\SiteSEO\AbilitiesRegister::can_edit_posts',
'meta' => [
'show_in_rest' => true,
'mcp' => ['public' => true],
],
]);
// siteseo-posts/list-missing-seo
wp_register_ability('siteseo-posts/list-missing-seo', [
'label' => __('List Posts Missing SEO Data', 'siteseo'),
'description' => __('Returns posts where one or more SEO fields are unset (title, description, or focus keyphrase). Useful for "which posts need SEO attention?" prompts.', 'siteseo'),
'category' => 'siteseo-posts',
'input_schema' => [
'type' => 'object',
'properties' => [
'missing_fields' => [
'type' => 'array',
'items' => [
'type' => 'string',
'enum' => ['title', 'description', 'focus_keyphrase'],
],
'default' => ['focus_keyphrase'],
],
'post_type' => [
'type' => 'array',
'items' => ['type' => 'string'],
],
'status' => [
'type' => 'array',
'items' => ['type' => 'string'],
'default' => ['publish'],
],
'limit' => [
'type' => 'integer',
'minimum' => 1,
'maximum' => 100,
'default' => 20,
],
'offset' => [
'type' => 'integer',
'minimum' => 0,
'default' => 0,
],
],
'additionalProperties' => false,
'default' => [],
],
'output_schema' => [
'type' => 'object',
'properties' => [
'posts' => [
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'id' => ['type' => 'integer'],
'post_title' => ['type' => 'string'],
'post_type' => ['type' => 'string'],
'status' => ['type' => 'string'],
'permalink' => ['type' => ['string', 'null']],
'missing_fields' => [
'type' => 'array',
'items' => ['type' => 'string'],
],
],
],
],
'total' => ['type' => 'integer'],
],
],
'execute_callback' => '\SiteSEO\AbilitiesRegister::list_posts_missing_seo',
'permission_callback' => '\SiteSEO\AbilitiesRegister::can_edit_posts',
'meta' => self::readonly_meta(),
]);
// siteseo-posts/list-score
wp_register_ability('siteseo-posts/list-score', [
'label' => __('List Posts by SEO Score', 'siteseo'),
'description' => __('Returns posts with their cached SEO content-analysis score. Useful for finding the worst (or best) performing content.', 'siteseo'),
'category' => 'siteseo-posts',
'input_schema' => [
'type' => 'object',
'properties' => [
'post_type' => [
'type' => 'array',
'items' => ['type' => 'string'],
],
'status' => [
'type' => 'array',
'items' => ['type' => 'string'],
'default' => ['publish'],
],
'limit' => [
'type' => 'integer',
'minimum' => 1,
'maximum' => 100,
'default' => 20,
],
'offset' => [
'type' => 'integer',
'minimum' => 0,
'default' => 0,
],
],
'additionalProperties' => false,
'default' => [],
],
'output_schema' => [
'type' => 'object',
'properties' => [
'posts' => [
'type' => 'array',
'items' => [
'type' => 'object',
'properties' => [
'id' => ['type' => 'integer'],
'post_title' => ['type' => 'string'],
'post_type' => ['type' => 'string'],
'status' => ['type' => 'string'],
'permalink' => ['type' => ['string', 'null']],
'seo_score' => ['type' => ['string', 'null']],
],
],
],
'total' => ['type' => 'integer'],
],
],
'execute_callback' => '\SiteSEO\AbilitiesRegister::list_posts_by_score',
'permission_callback' => '\SiteSEO\AbilitiesRegister::can_edit_posts',
'meta' => self::readonly_meta(),
]);
}
// =========================================================================
// Settings ability
// =========================================================================
protected static function register_settings_ability(){
wp_register_ability('siteseo-settings/get', [
'label' => __('Get SiteSEO Settings', 'siteseo'),
'description' => __('Returns the SiteSEO settings tree (titles, social, advanced, sitemap, analytics). Read-only: settings cannot be modified via abilities — use the SiteSEO admin UI.', 'siteseo'),
'category' => 'siteseo-settings',
'input_schema' => self::no_input_schema(),
'output_schema' => [
'type' => 'object',
'properties' => [
'settings' => [
'type' => 'object',
'description' => __('The SiteSEO settings tree. Shape is internal; treat as opaque structured data.', 'siteseo'),
],
],
],
'execute_callback' => '\SiteSEO\AbilitiesRegister::get_settings',
'permission_callback' => '\SiteSEO\AbilitiesRegister::can_manage_options',
'meta' => self::readonly_meta(),
]);
}
// =========================================================================
// Audit abilities
// =========================================================================
protected static function register_audit_abilities(){
// siteseo-audit/homepage-get
wp_register_ability('siteseo-audit/homepage-get', [
'label' => __('Get Homepage SEO Audit', 'siteseo'),
'description' => __('Returns an SEO snapshot of the homepage: title, meta description, robots flags, social meta and a content-analysis score (when cached).', 'siteseo'),
'category' => 'siteseo-audit',
'input_schema' => self::no_input_schema(),
'output_schema' => [
'type' => 'object',
'properties' => [
'url' => ['type' => 'string'],
'title' => ['type' => ['string', 'null']],
'description' => ['type' => ['string', 'null']],
'seo_score' => ['type' => ['string', 'null']],
'robots' => ['type' => 'string'],
],
],
'execute_callback' => '\SiteSEO\AbilitiesRegister::get_homepage_audit',
'permission_callback' => '\SiteSEO\AbilitiesRegister::can_manage_options',
'meta' => self::readonly_meta(),
]);
// siteseo-audit/site-get
wp_register_ability('siteseo-audit/site-get', [
'label' => __('Get Site SEO Audit', 'siteseo'),
'description' => __('Returns a site-wide SEO health snapshot: homepage score, sitemap on/off, sitemap index URL, custom robots rule count and public post type list.', 'siteseo'),
'category' => 'siteseo-audit',
'input_schema' => self::no_input_schema(),
'output_schema' => [
'type' => 'object',
'properties' => [
'sitemap_enabled' => ['type' => 'boolean'],
'sitemap_index_url' => ['type' => ['string', 'null']],
'custom_robots_rules_count' => ['type' => 'integer'],
'public_post_types' => [
'type' => 'array',
'items' => ['type' => 'string'],
],
'homepage_url' => ['type' => 'string'],
],
],
'execute_callback' => '\SiteSEO\AbilitiesRegister::get_site_audit',
'permission_callback' => '\SiteSEO\AbilitiesRegister::can_manage_options',
'meta' => self::readonly_meta(),
]);
}
// =========================================================================
// Sitemap abilities
// =========================================================================
protected static function register_sitemap_abilities(){
// siteseo-sitemap/status-get
wp_register_ability('siteseo-sitemap/status-get', [
'label' => __('Get Sitemap Status', 'siteseo'),
'description' => __('Returns the SiteSEO sitemap state: whether the XML sitemap is enabled, and the sitemap index URL.', 'siteseo'),
'category' => 'siteseo-sitemap',
'input_schema' => self::no_input_schema(),
'output_schema' => [
'type' => 'object',
'properties' => [
'enabled' => ['type' => 'boolean'],
'index_url' => ['type' => ['string', 'null']],
],
],
'execute_callback' => '\SiteSEO\AbilitiesRegister::get_sitemap_status',
'permission_callback' => '\SiteSEO\AbilitiesRegister::can_manage_options',
'meta' => self::readonly_meta(),
]);
}
// =========================================================================
// Execute callbacks — Posts
// =========================================================================
/**
* Build a post SEO snapshot from post meta.
*
* @param int $post_id
* @return array
*/
protected static function post_snapshot($post_id){
$post_id = (int)$post_id;
return [
'title' => get_post_meta($post_id, '_siteseo_titles_title', true) ?: null,
'description' => get_post_meta($post_id, '_siteseo_titles_desc', true) ?: null,
'canonical_url' => get_post_meta($post_id, '_siteseo_robots_canonical', true) ?: null,
'focus_keyphrase' => get_post_meta($post_id, '_siteseo_analysis_target_kw', true) ?: null,
'seo_score' => self::post_score($post_id),
'robots' => [
'noindex' => 'yes' === get_post_meta($post_id, '_siteseo_robots_index', true),
'nofollow' => 'yes' === get_post_meta($post_id, '_siteseo_robots_follow', true),
'noimageindex' => 'yes' === get_post_meta($post_id, '_siteseo_robots_imageindex', true),
'noarchive' => 'yes' === get_post_meta($post_id, '_siteseo_robots_archive', true),
'nosnippet' => 'yes' === get_post_meta($post_id, '_siteseo_robots_snippet', true),
],
'social' => [
'og_title' => get_post_meta($post_id, '_siteseo_social_fb_title', true) ?: null,
'og_description' => get_post_meta($post_id, '_siteseo_social_fb_desc', true) ?: null,
'og_image' => get_post_meta($post_id, '_siteseo_social_fb_img', true) ?: null,
'twitter_title' => get_post_meta($post_id, '_siteseo_social_twitter_title', true) ?: null,
'twitter_description' => get_post_meta($post_id, '_siteseo_social_twitter_desc', true) ?: null,
'twitter_image' => get_post_meta($post_id, '_siteseo_social_twitter_img', true) ?: null,
],
];
}
/**
* Read the cached SEO content-analysis score for a post.
*
* @param int $post_id
* @return string|null
*/
protected static function post_score($post_id){
$api = get_post_meta($post_id, '_siteseo_content_analysis_api', true);
if(is_array($api) && isset($api['score'])){
return (string)$api['score'];
}
$data = get_post_meta($post_id, '_siteseo_analysis_data', true);
if(is_array($data) && isset($data[0]['score'])){
return $data[0]['score'] === 1 ? 'good' : 'bad';
}
return null;
}
/**
* Execute callback for siteseo-posts/seo-data-get.
*
* @param array $input
* @return array|\WP_Error
*/
public static function get_post_seo_data($input){
$post_id = isset($input['postId']) ? (int)$input['postId'] : 0;
if(!$post_id || !get_post($post_id)){
return new \WP_Error('invalid_post', __('Post not found.', 'siteseo'));
}
return self::post_snapshot($post_id);
}
/**
* Execute callback for siteseo-posts/seo-data-update.
*
* @param array $input
* @return array|\WP_Error
*/
public static function update_post_seo_data($input){
$input = is_array($input) ? $input : [];
$post_id = isset($input['postId']) ? (int)$input['postId'] : 0;
if(!$post_id || !get_post($post_id)){
return new \WP_Error('invalid_post', __('Post not found.', 'siteseo'));
}
if(!current_user_can('edit_post', $post_id)){
return new \WP_Error('forbidden', __('You cannot edit this post.', 'siteseo'));
}
$meta_map = [
'title' => '_siteseo_titles_title',
'description' => '_siteseo_titles_desc',
'canonical_url' => '_siteseo_robots_canonical',
'focus_keyphrase' => '_siteseo_analysis_target_kw',
];
foreach($meta_map as $field => $meta_key){
if(array_key_exists($field, $input)){
$value = $input[$field];
if(null === $value){
delete_post_meta($post_id, $meta_key);
}else{
update_post_meta($post_id, $meta_key, sanitize_text_field($value));
}
}
}
$robots_map = [
'noindex' => '_siteseo_robots_index',
'nofollow' => '_siteseo_robots_follow',
'noimageindex' => '_siteseo_robots_imageindex',
'noarchive' => '_siteseo_robots_archive',
'nosnippet' => '_siteseo_robots_snippet',
];
if(isset($input['robots']) && is_array($input['robots'])){
foreach($robots_map as $flag => $meta_key){
if(array_key_exists($flag, $input['robots'])){
if($input['robots'][$flag]){
update_post_meta($post_id, $meta_key, 'yes');
}else{
delete_post_meta($post_id, $meta_key);
}
}
}
}
$social_map = [
'og_title' => '_siteseo_social_fb_title',
'og_description' => '_siteseo_social_fb_desc',
'og_image' => '_siteseo_social_fb_img',
'twitter_title' => '_siteseo_social_twitter_title',
'twitter_description' => '_siteseo_social_twitter_desc',
'twitter_image' => '_siteseo_social_twitter_img',
];
if(isset($input['social']) && is_array($input['social'])){
foreach($social_map as $field => $meta_key){
if(array_key_exists($field, $input['social'])){
$value = $input['social'][$field];
if(null === $value){
delete_post_meta($post_id, $meta_key);
}else{
update_post_meta($post_id, $meta_key, sanitize_text_field($value));
}
}
}
}
return [
'updated' => true,
'post' => self::post_snapshot($post_id),
];
}
/**
* Execute callback for siteseo-posts/list-missing-seo.
*
* @param array $input
* @return array
*/
public static function list_posts_missing_seo($input){
$input = is_array($input) ? $input : [];
$missing_fields = isset($input['missing_fields']) && is_array($input['missing_fields']) ? $input['missing_fields'] : ['focus_keyphrase'];
$post_types = isset($input['post_type']) && is_array($input['post_type']) ? $input['post_type'] : array_values(get_post_types(['public' => true]));
$statuses = isset($input['status']) && is_array($input['status']) ? $input['status'] : ['publish'];
$limit = isset($input['limit']) ? max(1, min(100, (int)$input['limit'])) : 20;
$offset = isset($input['offset']) ? max(0, (int)$input['offset']) : 0;
$meta_map = [
'title' => '_siteseo_titles_title',
'description' => '_siteseo_titles_desc',
'focus_keyphrase' => '_siteseo_analysis_target_kw',
];
$query = new \WP_Query([
'post_type' => $post_types,
'post_status' => $statuses,
'posts_per_page' => $limit,
'offset' => $offset,
'orderby' => 'date',
'order' => 'DESC',
'fields' => 'ids',
]);
$posts = [];
$total = $query->found_posts;
foreach($query->posts as $post_id){
$post_missing = [];
foreach($missing_fields as $field){
if(!isset($meta_map[$field])){
continue;
}
$value = get_post_meta($post_id, $meta_map[$field], true);
if('' === $value || null === $value){
$post_missing[] = $field;
}
}
if(!empty($post_missing)){
$post = get_post($post_id);
$posts[] = [
'id' => $post_id,
'post_title' => $post ? $post->post_title : '',
'post_type' => $post ? $post->post_type : '',
'status' => $post ? $post->post_status : '',
'permalink' => get_permalink($post_id) ?: null,
'missing_fields' => $post_missing,
];
}
}
return [
'posts' => $posts,
'total' => $total,
];
}
/**
* Execute callback for siteseo-posts/list-score.
*
* @param array $input
* @return array
*/
public static function list_posts_by_score($input){
$input = is_array($input) ? $input : [];
$post_types = isset($input['post_type']) && is_array($input['post_type']) ? $input['post_type'] : array_values(get_post_types(['public' => true]));
$statuses = isset($input['status']) && is_array($input['status']) ? $input['status'] : ['publish'];
$limit = isset($input['limit']) ? max(1, min(100, (int)$input['limit'])) : 20;
$offset = isset($input['offset']) ? max(0, (int)$input['offset']) : 0;
$query = new \WP_Query([
'post_type' => $post_types,
'post_status' => $statuses,
'posts_per_page' => $limit,
'offset' => $offset,
'orderby' => 'date',
'order' => 'DESC',
'fields' => 'ids',
]);
$posts = [];
foreach($query->posts as $post_id){
$post = get_post($post_id);
$posts[] = [
'id' => $post_id,
'post_title' => $post ? $post->post_title : '',
'post_type' => $post ? $post->post_type : '',
'status' => $post ? $post->post_status : '',
'permalink' => get_permalink($post_id) ?: null,
'seo_score' => self::post_score($post_id),
];
}
return [
'posts' => $posts,
'total' => $query->found_posts,
];
}
// =========================================================================
// Execute callbacks — Settings
// =========================================================================
/**
* Execute callback for siteseo-settings/get.
*
* @return array
*/
public static function get_settings(){
return [
'settings' => [
'titles' => get_option('siteseo_titles_option_name', []),
'social' => get_option('siteseo_social_option_name', []),
'advanced' => get_option('siteseo_advanced_option_name', []),
'sitemap' => get_option('siteseo_xml_sitemap_option_name', []),
'analytics' => get_option('siteseo_google_analytics_option_name', []),
'toggles' => get_option('siteseo_toggle', []),
],
];
}
// =========================================================================
// Execute callbacks — Audit
// =========================================================================
/**
* Execute callback for siteseo-audit/homepage-get.
*
* @return array
*/
public static function get_homepage_audit(){
$home_id = (int)get_option('page_on_front');
$snapshot = [
'url' => home_url('/'),
'title' => null,
'description' => null,
'seo_score' => null,
'robots' => '',
];
if($home_id){
$snapshot['title'] = get_post_meta($home_id, '_siteseo_titles_title', true) ?: null;
$snapshot['description'] = get_post_meta($home_id, '_siteseo_titles_desc', true) ?: null;
$snapshot['seo_score'] = self::post_score($home_id);
$robots = [];
if('yes' === get_post_meta($home_id, '_siteseo_robots_index', true)){
$robots[] = 'noindex';
}
if('yes' === get_post_meta($home_id, '_siteseo_robots_follow', true)){
$robots[] = 'nofollow';
}
$snapshot['robots'] = implode(', ', $robots);
}
return $snapshot;
}
/**
* Execute callback for siteseo-audit/site-get.
*
* @return array
*/
public static function get_site_audit(){
$sitemap_enabled = false;
if(function_exists('siteseo_get_toggle_option')){
$sitemap_enabled = '1' === siteseo_get_toggle_option('xml-sitemap');
}
$sitemap_index_url = null;
if($sitemap_enabled && function_exists('siteseo_get_service')){
$option = siteseo_get_service('SitemapOption');
if($option && '1' === $option->isEnabled()){
$sitemap_index_url = home_url('/sitemaps.xml');
}
}
$rules = self::parse_robots_rules(self::get_robots_file_content());
$post_types = array_values(get_post_types(['public' => true]));
return [
'sitemap_enabled' => $sitemap_enabled,
'sitemap_index_url' => $sitemap_index_url,
'custom_robots_rules_count' => count($rules),
'public_post_types' => $post_types,
'homepage_url' => home_url('/'),
];
}
// =========================================================================
// Execute callbacks — Sitemap
// =========================================================================
/**
* Execute callback for siteseo-sitemap/status-get.
*
* @return array
*/
public static function get_sitemap_status(){
$enabled = false;
if(function_exists('siteseo_get_toggle_option')){
$enabled = '1' === siteseo_get_toggle_option('xml-sitemap');
}
$index_url = null;
if($enabled && function_exists('siteseo_get_service')){
$option = siteseo_get_service('SitemapOption');
if($option && '1' === $option->isEnabled()){
$index_url = home_url('/sitemaps.xml');
}
}
return [
'enabled' => $enabled,
'index_url' => $index_url,
];
}
}