class-avcf-abilities-base.php
3 weeks ago
class-avcf-abilities-block-navigation.php
3 weeks ago
class-avcf-abilities-cache.php
3 weeks ago
class-avcf-abilities-content.php
1 week ago
class-avcf-abilities-core.php
3 days ago
class-avcf-abilities-execute-php.php
2 weeks ago
class-avcf-abilities-global-styles.php
3 weeks ago
class-avcf-abilities-gutenberg.php
2 weeks ago
class-avcf-abilities-media.php
1 week ago
class-avcf-abilities-metadata.php
1 week ago
class-avcf-abilities-navigation.php
3 weeks ago
class-avcf-abilities-patterns.php
3 weeks ago
class-avcf-abilities-plugins.php
3 days ago
class-avcf-abilities-readonly.php
2 weeks ago
class-avcf-abilities-settings.php
3 weeks ago
class-avcf-abilities-taxonomies.php
3 weeks ago
class-avcf-abilities-templates.php
3 weeks ago
class-avcf-abilities-theme-files.php
2 weeks ago
class-avcf-abilities-themes.php
3 days ago
class-avcf-abilities-users.php
3 weeks ago
class-avcf-abilities-wp-cli.php
3 days ago
class-avcf-abilities-settings.php
947 lines
| 1 | <?php |
| 2 | /** |
| 3 | * WordPress settings and configuration MCP abilities. |
| 4 | * |
| 5 | * Read and write the settings exposed by WordPress's Settings → General / |
| 6 | * Reading / Discussion / Permalinks / Media admin screens, plus read-only |
| 7 | * access to the live robots.txt output and the site's .htaccess file. |
| 8 | * |
| 9 | * Each settings page gets a matched get / update pair. Reads return the |
| 10 | * full settings group; writes accept any subset of the same fields and |
| 11 | * leave omitted fields unchanged. This matches the pattern established by |
| 12 | * get-content / update-content. |
| 13 | * |
| 14 | * Exposed abilities: |
| 15 | * atarim/get-general-settings Site identity, admin email, timezone, locale, date/time format. |
| 16 | * atarim/update-general-settings Update any subset of the above. |
| 17 | * atarim/get-reading-settings Front page config, posts-per-page, search engine visibility. |
| 18 | * atarim/update-reading-settings Update any subset, with page_on_front validation. |
| 19 | * atarim/get-discussion-settings ~20 options grouped (permissions, threading, notifications, moderation, spam, avatars). |
| 20 | * atarim/update-discussion-settings Update any subset. |
| 21 | * atarim/get-permalink-settings Structure + category/tag base. |
| 22 | * atarim/update-permalink-settings Update + flush rewrite rules. |
| 23 | * atarim/get-media-settings Thumbnail / medium / large sizes + yearmonth folder flag. |
| 24 | * atarim/update-media-settings Update any subset. |
| 25 | * atarim/get-robots-txt Current live robots.txt content (default + robots_txt filter applied). |
| 26 | * atarim/get-htaccess Raw .htaccess file contents from ABSPATH, or empty if not present. |
| 27 | * |
| 28 | * Note: ability names registered here must also be added to the $tools |
| 29 | * array in doit/class-avcf-mcp.php::avcf_mcp_setup_server() to be exposed |
| 30 | * by the MCP server. |
| 31 | * |
| 32 | * @package atarim-visual-collaboration |
| 33 | */ |
| 34 | |
| 35 | if ( ! defined('ABSPATH') ) { |
| 36 | exit; |
| 37 | } |
| 38 | |
| 39 | class AVCF_Abilities_Settings extends AVCF_Abilities_Base { |
| 40 | |
| 41 | public function register() { |
| 42 | |
| 43 | // ---- get-general-settings ---- |
| 44 | wp_register_ability( 'atarim/get-general-settings', [ |
| 45 | 'label' => 'Get General Settings', |
| 46 | 'description' => 'Returns the site identity settings shown on Settings → General: site title, tagline, admin email, site URL, home URL, locale, timezone, date format, time format, start of week.', |
| 47 | 'category' => 'atarim', |
| 48 | 'input_schema' => [ |
| 49 | 'type' => 'object', |
| 50 | 'properties' => new \stdClass(), |
| 51 | 'additionalProperties' => false, |
| 52 | ], |
| 53 | 'output_schema' => [ |
| 54 | 'type' => 'object', |
| 55 | 'properties' => [ |
| 56 | 'blogname' => [ 'type' => 'string' ], |
| 57 | 'blogdescription' => [ 'type' => 'string' ], |
| 58 | 'admin_email' => [ 'type' => 'string' ], |
| 59 | 'siteurl' => [ 'type' => 'string' ], |
| 60 | 'home' => [ 'type' => 'string' ], |
| 61 | 'locale' => [ 'type' => 'string' ], |
| 62 | 'timezone_string' => [ 'type' => 'string' ], |
| 63 | 'gmt_offset' => [ 'type' => 'string' ], |
| 64 | 'date_format' => [ 'type' => 'string' ], |
| 65 | 'time_format' => [ 'type' => 'string' ], |
| 66 | 'start_of_week' => [ 'type' => 'integer' ], |
| 67 | ], |
| 68 | 'required' => [ 'blogname', 'admin_email' ], |
| 69 | ], |
| 70 | 'execute_callback' => function( $input = [] ) { |
| 71 | return [ |
| 72 | 'blogname' => (string) get_option( 'blogname', '' ), |
| 73 | 'blogdescription' => (string) get_option( 'blogdescription', '' ), |
| 74 | 'admin_email' => (string) get_option( 'admin_email', '' ), |
| 75 | 'siteurl' => (string) get_option( 'siteurl', '' ), |
| 76 | 'home' => (string) get_option( 'home', '' ), |
| 77 | 'locale' => (string) get_locale(), |
| 78 | 'timezone_string' => (string) get_option( 'timezone_string', '' ), |
| 79 | 'gmt_offset' => (string) get_option( 'gmt_offset', '0' ), |
| 80 | 'date_format' => (string) get_option( 'date_format', '' ), |
| 81 | 'time_format' => (string) get_option( 'time_format', '' ), |
| 82 | 'start_of_week' => (int) get_option( 'start_of_week', 1 ), |
| 83 | ]; |
| 84 | }, |
| 85 | 'permission_callback' => function() { |
| 86 | return current_user_can( 'manage_options' ); |
| 87 | }, |
| 88 | 'meta' => [ |
| 89 | 'mcp' => [ 'public' => true, 'type' => 'tool' ], |
| 90 | 'annotations' => [ 'readonly' => true, 'destructive' => false, 'idempotent' => true ], |
| 91 | ], |
| 92 | ] ); |
| 93 | |
| 94 | // ---- update-general-settings ---- |
| 95 | wp_register_ability( 'atarim/update-general-settings', [ |
| 96 | 'label' => 'Update General Settings', |
| 97 | 'description' => 'Updates any subset of the General settings. Pass only the fields you want to change; omitted fields are left unchanged. NOTE: admin_email is set DIRECTLY without WordPress\'s "verify the new email" double-opt-in flow, since AI workflows are admin-trusted and there is no human at the new address to click the confirmation link. Timezone accepts either a timezone_string (Region/City, recommended) OR a gmt_offset (-12 to 14, half-hour increments allowed) — not both. Setting one clears the other to match WordPress\'s own admin behaviour.', |
| 98 | 'category' => 'atarim', |
| 99 | 'input_schema' => [ |
| 100 | 'type' => 'object', |
| 101 | 'properties' => [ |
| 102 | 'blogname' => [ 'type' => 'string', 'description' => 'Site title.' ], |
| 103 | 'blogdescription' => [ 'type' => 'string', 'description' => 'Site tagline.' ], |
| 104 | 'admin_email' => [ 'type' => 'string', 'description' => 'Administrator email address. Validated for format; set directly without verification flow.' ], |
| 105 | 'siteurl' => [ 'type' => 'string', 'description' => 'WordPress address (URL). Be cautious — wrong values can lock the admin out.' ], |
| 106 | 'home' => [ 'type' => 'string', 'description' => 'Site URL the public sees. Often equal to siteurl.' ], |
| 107 | 'timezone_string' => [ 'type' => 'string', 'description' => 'Region/City timezone (e.g. "America/New_York"). Setting this clears gmt_offset.' ], |
| 108 | 'gmt_offset' => [ 'type' => [ 'string', 'number' ], 'description' => 'Numeric offset from UTC (-12 to 14, half-hour increments). Setting this clears timezone_string.' ], |
| 109 | 'date_format' => [ 'type' => 'string', 'description' => 'PHP date format string (e.g. "F j, Y").' ], |
| 110 | 'time_format' => [ 'type' => 'string', 'description' => 'PHP date format string for time (e.g. "g:i a").' ], |
| 111 | 'start_of_week' => [ 'type' => 'integer', 'minimum' => 0, 'maximum' => 6, 'description' => '0=Sunday, 1=Monday, ... 6=Saturday.' ], |
| 112 | ], |
| 113 | 'additionalProperties' => false, |
| 114 | ], |
| 115 | 'output_schema' => [ |
| 116 | 'type' => 'object', |
| 117 | 'properties' => [ |
| 118 | 'success' => [ 'type' => 'boolean' ], |
| 119 | 'updated' => [ 'type' => 'array', 'items' => [ 'type' => 'string' ] ], |
| 120 | 'message' => [ 'type' => 'string' ], |
| 121 | ], |
| 122 | 'required' => [ 'success', 'message' ], |
| 123 | ], |
| 124 | 'execute_callback' => function( $input = [] ) { |
| 125 | $updated = []; |
| 126 | |
| 127 | if ( array_key_exists( 'blogname', $input ) ) { |
| 128 | update_option( 'blogname', sanitize_text_field( (string) $input['blogname'] ) ); |
| 129 | $updated[] = 'blogname'; |
| 130 | } |
| 131 | if ( array_key_exists( 'blogdescription', $input ) ) { |
| 132 | update_option( 'blogdescription', sanitize_text_field( (string) $input['blogdescription'] ) ); |
| 133 | $updated[] = 'blogdescription'; |
| 134 | } |
| 135 | if ( array_key_exists( 'admin_email', $input ) ) { |
| 136 | $email = sanitize_email( (string) $input['admin_email'] ); |
| 137 | if ( ! is_email( $email ) ) { |
| 138 | return [ 'success' => false, 'updated' => $updated, 'message' => sprintf( 'admin_email "%s" is not a valid email address.', $input['admin_email'] ) ]; |
| 139 | } |
| 140 | // Bypass WP's pending-verification flow — write directly. |
| 141 | update_option( 'admin_email', $email ); |
| 142 | // Clear any pending verification state from a previous admin-UI change. |
| 143 | delete_option( 'adminhash' ); |
| 144 | delete_option( 'new_admin_email' ); |
| 145 | $updated[] = 'admin_email'; |
| 146 | } |
| 147 | if ( array_key_exists( 'siteurl', $input ) ) { |
| 148 | update_option( 'siteurl', esc_url_raw( (string) $input['siteurl'] ) ); |
| 149 | $updated[] = 'siteurl'; |
| 150 | } |
| 151 | if ( array_key_exists( 'home', $input ) ) { |
| 152 | update_option( 'home', esc_url_raw( (string) $input['home'] ) ); |
| 153 | $updated[] = 'home'; |
| 154 | } |
| 155 | // Timezone: setting one clears the other (WP admin behaviour). |
| 156 | if ( array_key_exists( 'timezone_string', $input ) && array_key_exists( 'gmt_offset', $input ) ) { |
| 157 | return [ 'success' => false, 'updated' => $updated, 'message' => 'Pass either timezone_string OR gmt_offset, not both.' ]; |
| 158 | } |
| 159 | if ( array_key_exists( 'timezone_string', $input ) ) { |
| 160 | $tz = (string) $input['timezone_string']; |
| 161 | if ( $tz !== '' && ! in_array( $tz, timezone_identifiers_list(), true ) ) { |
| 162 | return [ 'success' => false, 'updated' => $updated, 'message' => sprintf( 'Unknown timezone "%s". Use a Region/City identifier (e.g. "America/New_York").', $tz ) ]; |
| 163 | } |
| 164 | update_option( 'timezone_string', $tz ); |
| 165 | update_option( 'gmt_offset', '0' ); |
| 166 | $updated[] = 'timezone_string'; |
| 167 | } |
| 168 | if ( array_key_exists( 'gmt_offset', $input ) ) { |
| 169 | $offset = (float) $input['gmt_offset']; |
| 170 | if ( $offset < -12 || $offset > 14 ) { |
| 171 | return [ 'success' => false, 'updated' => $updated, 'message' => 'gmt_offset must be between -12 and 14.' ]; |
| 172 | } |
| 173 | update_option( 'gmt_offset', (string) $offset ); |
| 174 | update_option( 'timezone_string', '' ); |
| 175 | $updated[] = 'gmt_offset'; |
| 176 | } |
| 177 | if ( array_key_exists( 'date_format', $input ) ) { |
| 178 | update_option( 'date_format', sanitize_text_field( (string) $input['date_format'] ) ); |
| 179 | $updated[] = 'date_format'; |
| 180 | } |
| 181 | if ( array_key_exists( 'time_format', $input ) ) { |
| 182 | update_option( 'time_format', sanitize_text_field( (string) $input['time_format'] ) ); |
| 183 | $updated[] = 'time_format'; |
| 184 | } |
| 185 | if ( array_key_exists( 'start_of_week', $input ) ) { |
| 186 | $sow = (int) $input['start_of_week']; |
| 187 | if ( $sow < 0 || $sow > 6 ) { |
| 188 | return [ 'success' => false, 'updated' => $updated, 'message' => 'start_of_week must be between 0 (Sunday) and 6 (Saturday).' ]; |
| 189 | } |
| 190 | update_option( 'start_of_week', $sow ); |
| 191 | $updated[] = 'start_of_week'; |
| 192 | } |
| 193 | |
| 194 | if ( empty( $updated ) ) { |
| 195 | return [ 'success' => false, 'updated' => [], 'message' => 'No fields provided to update.' ]; |
| 196 | } |
| 197 | |
| 198 | return [ |
| 199 | 'success' => true, |
| 200 | 'updated' => $updated, |
| 201 | 'message' => sprintf( 'Updated: %s.', implode( ', ', $updated ) ), |
| 202 | ]; |
| 203 | }, |
| 204 | 'permission_callback' => function() { |
| 205 | return current_user_can( 'manage_options' ); |
| 206 | }, |
| 207 | 'meta' => [ |
| 208 | 'mcp' => [ 'public' => true, 'type' => 'tool' ], |
| 209 | 'annotations' => [ 'readonly' => false, 'destructive' => false, 'idempotent' => true ], |
| 210 | ], |
| 211 | ] ); |
| 212 | |
| 213 | // ---- get-reading-settings ---- |
| 214 | wp_register_ability( 'atarim/get-reading-settings', [ |
| 215 | 'label' => 'Get Reading Settings', |
| 216 | 'description' => 'Returns the Settings → Reading values: front page display (latest posts vs. static page), the static page IDs if applicable, posts per page, RSS post count, RSS excerpt mode, and search engine visibility ("discourage search engines" checkbox).', |
| 217 | 'category' => 'atarim', |
| 218 | 'input_schema' => [ |
| 219 | 'type' => 'object', |
| 220 | 'properties' => new \stdClass(), |
| 221 | 'additionalProperties' => false, |
| 222 | ], |
| 223 | 'output_schema' => [ |
| 224 | 'type' => 'object', |
| 225 | 'properties' => [ |
| 226 | 'show_on_front' => [ 'type' => 'string' ], |
| 227 | 'page_on_front' => [ 'type' => 'integer' ], |
| 228 | 'page_for_posts' => [ 'type' => 'integer' ], |
| 229 | 'posts_per_page' => [ 'type' => 'integer' ], |
| 230 | 'posts_per_rss' => [ 'type' => 'integer' ], |
| 231 | 'rss_use_excerpt' => [ 'type' => 'boolean' ], |
| 232 | 'search_engine_visible' => [ 'type' => 'boolean' ], |
| 233 | ], |
| 234 | 'required' => [ 'show_on_front', 'posts_per_page' ], |
| 235 | ], |
| 236 | 'execute_callback' => function( $input = [] ) { |
| 237 | return [ |
| 238 | 'show_on_front' => (string) get_option( 'show_on_front', 'posts' ), |
| 239 | 'page_on_front' => (int) get_option( 'page_on_front', 0 ), |
| 240 | 'page_for_posts' => (int) get_option( 'page_for_posts', 0 ), |
| 241 | 'posts_per_page' => (int) get_option( 'posts_per_page', 10 ), |
| 242 | 'posts_per_rss' => (int) get_option( 'posts_per_rss', 10 ), |
| 243 | 'rss_use_excerpt' => (bool) get_option( 'rss_use_excerpt', 0 ), |
| 244 | // blog_public stores 1=allow, 0=discourage. Surface as a clearer name. |
| 245 | 'search_engine_visible' => (bool) get_option( 'blog_public', 1 ), |
| 246 | ]; |
| 247 | }, |
| 248 | 'permission_callback' => function() { |
| 249 | return current_user_can( 'manage_options' ); |
| 250 | }, |
| 251 | 'meta' => [ |
| 252 | 'mcp' => [ 'public' => true, 'type' => 'tool' ], |
| 253 | 'annotations' => [ 'readonly' => true, 'destructive' => false, 'idempotent' => true ], |
| 254 | ], |
| 255 | ] ); |
| 256 | |
| 257 | // ---- update-reading-settings ---- |
| 258 | wp_register_ability( 'atarim/update-reading-settings', [ |
| 259 | 'label' => 'Update Reading Settings', |
| 260 | 'description' => 'Updates any subset of Settings → Reading. Pass show_on_front: "page" to use a static homepage — requires page_on_front to be set to an actual published page ID. To revert to latest-posts homepage, pass show_on_front: "posts" (page_on_front and page_for_posts are then ignored). search_engine_visible: false sets WordPress\'s "discourage search engines" flag.', |
| 261 | 'category' => 'atarim', |
| 262 | 'input_schema' => [ |
| 263 | 'type' => 'object', |
| 264 | 'properties' => [ |
| 265 | 'show_on_front' => [ 'type' => 'string', 'enum' => [ 'posts', 'page' ] ], |
| 266 | 'page_on_front' => [ 'type' => 'integer', 'minimum' => 0, 'description' => 'Page ID for the static homepage. Only meaningful if show_on_front is "page".' ], |
| 267 | 'page_for_posts' => [ 'type' => 'integer', 'minimum' => 0, 'description' => 'Page ID used as the posts archive. Optional even when show_on_front is "page".' ], |
| 268 | 'posts_per_page' => [ 'type' => 'integer', 'minimum' => 1, 'maximum' => 1000 ], |
| 269 | 'posts_per_rss' => [ 'type' => 'integer', 'minimum' => 1, 'maximum' => 1000 ], |
| 270 | 'rss_use_excerpt' => [ 'type' => 'boolean' ], |
| 271 | 'search_engine_visible' => [ 'type' => 'boolean', 'description' => 'false sets the "discourage search engines" flag (stored as blog_public=0).' ], |
| 272 | ], |
| 273 | 'additionalProperties' => false, |
| 274 | ], |
| 275 | 'output_schema' => [ |
| 276 | 'type' => 'object', |
| 277 | 'properties' => [ |
| 278 | 'success' => [ 'type' => 'boolean' ], |
| 279 | 'updated' => [ 'type' => 'array', 'items' => [ 'type' => 'string' ] ], |
| 280 | 'message' => [ 'type' => 'string' ], |
| 281 | ], |
| 282 | 'required' => [ 'success', 'message' ], |
| 283 | ], |
| 284 | 'execute_callback' => function( $input = [] ) { |
| 285 | $updated = []; |
| 286 | |
| 287 | // Resolve the effective show_on_front for validation purposes. |
| 288 | $effective_show_on_front = array_key_exists( 'show_on_front', $input ) |
| 289 | ? (string) $input['show_on_front'] |
| 290 | : (string) get_option( 'show_on_front', 'posts' ); |
| 291 | |
| 292 | if ( array_key_exists( 'show_on_front', $input ) ) { |
| 293 | $sof = (string) $input['show_on_front']; |
| 294 | if ( ! in_array( $sof, [ 'posts', 'page' ], true ) ) { |
| 295 | return [ 'success' => false, 'updated' => $updated, 'message' => 'show_on_front must be "posts" or "page".' ]; |
| 296 | } |
| 297 | update_option( 'show_on_front', $sof ); |
| 298 | $updated[] = 'show_on_front'; |
| 299 | } |
| 300 | |
| 301 | if ( array_key_exists( 'page_on_front', $input ) ) { |
| 302 | $pid = (int) $input['page_on_front']; |
| 303 | if ( $pid > 0 && $effective_show_on_front === 'page' ) { |
| 304 | $page = get_post( $pid ); |
| 305 | if ( ! $page || $page->post_type !== 'page' || $page->post_status !== 'publish' ) { |
| 306 | return [ 'success' => false, 'updated' => $updated, 'message' => sprintf( 'page_on_front %d is not a published page.', $pid ) ]; |
| 307 | } |
| 308 | } |
| 309 | update_option( 'page_on_front', $pid ); |
| 310 | $updated[] = 'page_on_front'; |
| 311 | } |
| 312 | |
| 313 | if ( array_key_exists( 'page_for_posts', $input ) ) { |
| 314 | $pid = (int) $input['page_for_posts']; |
| 315 | if ( $pid > 0 ) { |
| 316 | $page = get_post( $pid ); |
| 317 | if ( ! $page || $page->post_type !== 'page' || $page->post_status !== 'publish' ) { |
| 318 | return [ 'success' => false, 'updated' => $updated, 'message' => sprintf( 'page_for_posts %d is not a published page.', $pid ) ]; |
| 319 | } |
| 320 | } |
| 321 | update_option( 'page_for_posts', $pid ); |
| 322 | $updated[] = 'page_for_posts'; |
| 323 | } |
| 324 | |
| 325 | if ( array_key_exists( 'posts_per_page', $input ) ) { |
| 326 | update_option( 'posts_per_page', max( 1, (int) $input['posts_per_page'] ) ); |
| 327 | $updated[] = 'posts_per_page'; |
| 328 | } |
| 329 | if ( array_key_exists( 'posts_per_rss', $input ) ) { |
| 330 | update_option( 'posts_per_rss', max( 1, (int) $input['posts_per_rss'] ) ); |
| 331 | $updated[] = 'posts_per_rss'; |
| 332 | } |
| 333 | if ( array_key_exists( 'rss_use_excerpt', $input ) ) { |
| 334 | update_option( 'rss_use_excerpt', $input['rss_use_excerpt'] ? 1 : 0 ); |
| 335 | $updated[] = 'rss_use_excerpt'; |
| 336 | } |
| 337 | if ( array_key_exists( 'search_engine_visible', $input ) ) { |
| 338 | update_option( 'blog_public', $input['search_engine_visible'] ? 1 : 0 ); |
| 339 | $updated[] = 'search_engine_visible'; |
| 340 | } |
| 341 | |
| 342 | if ( empty( $updated ) ) { |
| 343 | return [ 'success' => false, 'updated' => [], 'message' => 'No fields provided to update.' ]; |
| 344 | } |
| 345 | |
| 346 | return [ |
| 347 | 'success' => true, |
| 348 | 'updated' => $updated, |
| 349 | 'message' => sprintf( 'Updated: %s.', implode( ', ', $updated ) ), |
| 350 | ]; |
| 351 | }, |
| 352 | 'permission_callback' => function() { |
| 353 | return current_user_can( 'manage_options' ); |
| 354 | }, |
| 355 | 'meta' => [ |
| 356 | 'mcp' => [ 'public' => true, 'type' => 'tool' ], |
| 357 | 'annotations' => [ 'readonly' => false, 'destructive' => false, 'idempotent' => true ], |
| 358 | ], |
| 359 | ] ); |
| 360 | |
| 361 | // ---- get-discussion-settings ---- |
| 362 | wp_register_ability( 'atarim/get-discussion-settings', [ |
| 363 | 'label' => 'Get Discussion Settings', |
| 364 | 'description' => 'Returns the Settings → Discussion values, grouped logically for clarity: permissions (default statuses, registration requirement, name/email requirement, old-post closing), threading (depth, pagination, order), notifications (admin email on new comment / pending moderation), moderation (require approval, previously-approved trust, link threshold), spam (moderation_keys = "review if contains" blocklist; disallowed_keys = "auto-trash if contains" disallowed list), avatars (visibility, max rating, default style).', |
| 365 | 'category' => 'atarim', |
| 366 | 'input_schema' => [ |
| 367 | 'type' => 'object', |
| 368 | 'properties' => new \stdClass(), |
| 369 | 'additionalProperties' => false, |
| 370 | ], |
| 371 | 'output_schema' => [ |
| 372 | 'type' => 'object', |
| 373 | 'properties' => [ |
| 374 | 'permissions' => [ 'type' => 'object' ], |
| 375 | 'threading' => [ 'type' => 'object' ], |
| 376 | 'notifications' => [ 'type' => 'object' ], |
| 377 | 'moderation' => [ 'type' => 'object' ], |
| 378 | 'spam' => [ 'type' => 'object' ], |
| 379 | 'avatars' => [ 'type' => 'object' ], |
| 380 | ], |
| 381 | 'required' => [ 'permissions', 'threading', 'notifications', 'moderation', 'spam', 'avatars' ], |
| 382 | ], |
| 383 | 'execute_callback' => function( $input = [] ) { |
| 384 | return [ |
| 385 | 'permissions' => [ |
| 386 | 'default_comment_status' => (string) get_option( 'default_comment_status', 'open' ), |
| 387 | 'default_ping_status' => (string) get_option( 'default_ping_status', 'open' ), |
| 388 | 'default_pingback_flag' => (bool) get_option( 'default_pingback_flag', 1 ), |
| 389 | 'comment_registration' => (bool) get_option( 'comment_registration', 0 ), |
| 390 | 'require_name_email' => (bool) get_option( 'require_name_email', 1 ), |
| 391 | 'close_comments_for_old_posts' => (bool) get_option( 'close_comments_for_old_posts', 0 ), |
| 392 | 'close_comments_days_old' => (int) get_option( 'close_comments_days_old', 14 ), |
| 393 | ], |
| 394 | 'threading' => [ |
| 395 | 'thread_comments' => (bool) get_option( 'thread_comments', 1 ), |
| 396 | 'thread_comments_depth' => (int) get_option( 'thread_comments_depth', 5 ), |
| 397 | 'page_comments' => (bool) get_option( 'page_comments', 0 ), |
| 398 | 'comments_per_page' => (int) get_option( 'comments_per_page', 50 ), |
| 399 | 'default_comments_page' => (string) get_option( 'default_comments_page', 'newest' ), |
| 400 | 'comment_order' => (string) get_option( 'comment_order', 'asc' ), |
| 401 | ], |
| 402 | 'notifications' => [ |
| 403 | 'comments_notify' => (bool) get_option( 'comments_notify', 1 ), |
| 404 | 'moderation_notify' => (bool) get_option( 'moderation_notify', 1 ), |
| 405 | ], |
| 406 | 'moderation' => [ |
| 407 | 'comment_moderation' => (bool) get_option( 'comment_moderation', 0 ), |
| 408 | 'comment_previously_approved' => (bool) get_option( 'comment_previously_approved', 1 ), |
| 409 | 'comment_max_links' => (int) get_option( 'comment_max_links', 2 ), |
| 410 | ], |
| 411 | 'spam' => [ |
| 412 | // moderation_keys = words that send a comment to the moderation queue. |
| 413 | // disallowed_keys = words that send the comment to trash immediately. |
| 414 | 'moderation_keys' => (string) get_option( 'moderation_keys', '' ), |
| 415 | 'disallowed_keys' => (string) get_option( 'disallowed_keys', '' ), |
| 416 | ], |
| 417 | 'avatars' => [ |
| 418 | 'show_avatars' => (bool) get_option( 'show_avatars', 1 ), |
| 419 | 'avatar_rating' => (string) get_option( 'avatar_rating', 'G' ), |
| 420 | 'avatar_default' => (string) get_option( 'avatar_default', 'mystery' ), |
| 421 | ], |
| 422 | ]; |
| 423 | }, |
| 424 | 'permission_callback' => function() { |
| 425 | return current_user_can( 'manage_options' ); |
| 426 | }, |
| 427 | 'meta' => [ |
| 428 | 'mcp' => [ 'public' => true, 'type' => 'tool' ], |
| 429 | 'annotations' => [ 'readonly' => true, 'destructive' => false, 'idempotent' => true ], |
| 430 | ], |
| 431 | ] ); |
| 432 | |
| 433 | // ---- update-discussion-settings ---- |
| 434 | wp_register_ability( 'atarim/update-discussion-settings', [ |
| 435 | 'label' => 'Update Discussion Settings', |
| 436 | 'description' => 'Updates any subset of Settings → Discussion. Same grouped shape as get-discussion-settings; pass any of the six groups (permissions / threading / notifications / moderation / spam / avatars) with any subset of fields. Omitted fields and omitted groups are left unchanged.', |
| 437 | 'category' => 'atarim', |
| 438 | 'input_schema' => [ |
| 439 | 'type' => 'object', |
| 440 | 'properties' => [ |
| 441 | 'permissions' => [ |
| 442 | 'type' => 'object', |
| 443 | 'properties' => [ |
| 444 | 'default_comment_status' => [ 'type' => 'string', 'enum' => [ 'open', 'closed' ] ], |
| 445 | 'default_ping_status' => [ 'type' => 'string', 'enum' => [ 'open', 'closed' ] ], |
| 446 | 'default_pingback_flag' => [ 'type' => 'boolean' ], |
| 447 | 'comment_registration' => [ 'type' => 'boolean' ], |
| 448 | 'require_name_email' => [ 'type' => 'boolean' ], |
| 449 | 'close_comments_for_old_posts' => [ 'type' => 'boolean' ], |
| 450 | 'close_comments_days_old' => [ 'type' => 'integer', 'minimum' => 1 ], |
| 451 | ], |
| 452 | 'additionalProperties' => false, |
| 453 | ], |
| 454 | 'threading' => [ |
| 455 | 'type' => 'object', |
| 456 | 'properties' => [ |
| 457 | 'thread_comments' => [ 'type' => 'boolean' ], |
| 458 | 'thread_comments_depth' => [ 'type' => 'integer', 'minimum' => 1, 'maximum' => 10 ], |
| 459 | 'page_comments' => [ 'type' => 'boolean' ], |
| 460 | 'comments_per_page' => [ 'type' => 'integer', 'minimum' => 1, 'maximum' => 1000 ], |
| 461 | 'default_comments_page' => [ 'type' => 'string', 'enum' => [ 'newest', 'oldest' ] ], |
| 462 | 'comment_order' => [ 'type' => 'string', 'enum' => [ 'asc', 'desc' ] ], |
| 463 | ], |
| 464 | 'additionalProperties' => false, |
| 465 | ], |
| 466 | 'notifications' => [ |
| 467 | 'type' => 'object', |
| 468 | 'properties' => [ |
| 469 | 'comments_notify' => [ 'type' => 'boolean' ], |
| 470 | 'moderation_notify' => [ 'type' => 'boolean' ], |
| 471 | ], |
| 472 | 'additionalProperties' => false, |
| 473 | ], |
| 474 | 'moderation' => [ |
| 475 | 'type' => 'object', |
| 476 | 'properties' => [ |
| 477 | 'comment_moderation' => [ 'type' => 'boolean' ], |
| 478 | 'comment_previously_approved' => [ 'type' => 'boolean' ], |
| 479 | 'comment_max_links' => [ 'type' => 'integer', 'minimum' => 0 ], |
| 480 | ], |
| 481 | 'additionalProperties' => false, |
| 482 | ], |
| 483 | 'spam' => [ |
| 484 | 'type' => 'object', |
| 485 | 'properties' => [ |
| 486 | 'moderation_keys' => [ 'type' => 'string', 'description' => 'Newline-separated list of words/phrases. A comment containing any of these goes to the moderation queue.' ], |
| 487 | 'disallowed_keys' => [ 'type' => 'string', 'description' => 'Newline-separated list of words/phrases. A comment containing any of these is sent to trash immediately.' ], |
| 488 | ], |
| 489 | 'additionalProperties' => false, |
| 490 | ], |
| 491 | 'avatars' => [ |
| 492 | 'type' => 'object', |
| 493 | 'properties' => [ |
| 494 | 'show_avatars' => [ 'type' => 'boolean' ], |
| 495 | 'avatar_rating' => [ 'type' => 'string', 'enum' => [ 'G', 'PG', 'R', 'X' ] ], |
| 496 | 'avatar_default' => [ 'type' => 'string', 'description' => 'Avatar style: mystery, blank, gravatar_default, identicon, wavatar, monsterid, retro.' ], |
| 497 | ], |
| 498 | 'additionalProperties' => false, |
| 499 | ], |
| 500 | ], |
| 501 | 'additionalProperties' => false, |
| 502 | ], |
| 503 | 'output_schema' => [ |
| 504 | 'type' => 'object', |
| 505 | 'properties' => [ |
| 506 | 'success' => [ 'type' => 'boolean' ], |
| 507 | 'updated' => [ 'type' => 'array', 'items' => [ 'type' => 'string' ] ], |
| 508 | 'message' => [ 'type' => 'string' ], |
| 509 | ], |
| 510 | 'required' => [ 'success', 'message' ], |
| 511 | ], |
| 512 | 'execute_callback' => function( $input = [] ) { |
| 513 | $updated = []; |
| 514 | |
| 515 | // Map each group's fields to (option_key, coercion) tuples and walk them. |
| 516 | $bool_to_int = function( $v ) { return $v ? 1 : 0; }; |
| 517 | $as_string = function( $v ) { return sanitize_text_field( (string) $v ); }; |
| 518 | $as_textarea = function( $v ) { return sanitize_textarea_field( (string) $v ); }; |
| 519 | $as_int = function( $v ) { return (int) $v; }; |
| 520 | |
| 521 | $groups = [ |
| 522 | 'permissions' => [ |
| 523 | 'default_comment_status' => [ 'default_comment_status', $as_string ], |
| 524 | 'default_ping_status' => [ 'default_ping_status', $as_string ], |
| 525 | 'default_pingback_flag' => [ 'default_pingback_flag', $bool_to_int ], |
| 526 | 'comment_registration' => [ 'comment_registration', $bool_to_int ], |
| 527 | 'require_name_email' => [ 'require_name_email', $bool_to_int ], |
| 528 | 'close_comments_for_old_posts' => [ 'close_comments_for_old_posts', $bool_to_int ], |
| 529 | 'close_comments_days_old' => [ 'close_comments_days_old', $as_int ], |
| 530 | ], |
| 531 | 'threading' => [ |
| 532 | 'thread_comments' => [ 'thread_comments', $bool_to_int ], |
| 533 | 'thread_comments_depth' => [ 'thread_comments_depth', $as_int ], |
| 534 | 'page_comments' => [ 'page_comments', $bool_to_int ], |
| 535 | 'comments_per_page' => [ 'comments_per_page', $as_int ], |
| 536 | 'default_comments_page' => [ 'default_comments_page', $as_string ], |
| 537 | 'comment_order' => [ 'comment_order', $as_string ], |
| 538 | ], |
| 539 | 'notifications' => [ |
| 540 | 'comments_notify' => [ 'comments_notify', $bool_to_int ], |
| 541 | 'moderation_notify' => [ 'moderation_notify', $bool_to_int ], |
| 542 | ], |
| 543 | 'moderation' => [ |
| 544 | 'comment_moderation' => [ 'comment_moderation', $bool_to_int ], |
| 545 | 'comment_previously_approved' => [ 'comment_previously_approved', $bool_to_int ], |
| 546 | 'comment_max_links' => [ 'comment_max_links', $as_int ], |
| 547 | ], |
| 548 | 'spam' => [ |
| 549 | 'moderation_keys' => [ 'moderation_keys', $as_textarea ], |
| 550 | 'disallowed_keys' => [ 'disallowed_keys', $as_textarea ], |
| 551 | ], |
| 552 | 'avatars' => [ |
| 553 | 'show_avatars' => [ 'show_avatars', $bool_to_int ], |
| 554 | 'avatar_rating' => [ 'avatar_rating', $as_string ], |
| 555 | 'avatar_default' => [ 'avatar_default', $as_string ], |
| 556 | ], |
| 557 | ]; |
| 558 | |
| 559 | foreach ( $groups as $group_name => $fields ) { |
| 560 | if ( ! array_key_exists( $group_name, $input ) || ! is_array( $input[ $group_name ] ) ) { |
| 561 | continue; |
| 562 | } |
| 563 | foreach ( $fields as $field_name => $spec ) { |
| 564 | if ( ! array_key_exists( $field_name, $input[ $group_name ] ) ) { |
| 565 | continue; |
| 566 | } |
| 567 | list( $option_key, $coerce ) = $spec; |
| 568 | update_option( $option_key, $coerce( $input[ $group_name ][ $field_name ] ) ); |
| 569 | $updated[] = $group_name . '.' . $field_name; |
| 570 | } |
| 571 | } |
| 572 | |
| 573 | if ( empty( $updated ) ) { |
| 574 | return [ 'success' => false, 'updated' => [], 'message' => 'No fields provided to update.' ]; |
| 575 | } |
| 576 | |
| 577 | return [ |
| 578 | 'success' => true, |
| 579 | 'updated' => $updated, |
| 580 | 'message' => sprintf( 'Updated %d field(s): %s.', count( $updated ), implode( ', ', $updated ) ), |
| 581 | ]; |
| 582 | }, |
| 583 | 'permission_callback' => function() { |
| 584 | return current_user_can( 'manage_options' ); |
| 585 | }, |
| 586 | 'meta' => [ |
| 587 | 'mcp' => [ 'public' => true, 'type' => 'tool' ], |
| 588 | 'annotations' => [ 'readonly' => false, 'destructive' => false, 'idempotent' => true ], |
| 589 | ], |
| 590 | ] ); |
| 591 | |
| 592 | // ---- get-permalink-settings ---- |
| 593 | wp_register_ability( 'atarim/get-permalink-settings', [ |
| 594 | 'label' => 'Get Permalink Settings', |
| 595 | 'description' => 'Returns the permalink structure plus the category and tag URL bases. permalink_structure is the URL template (e.g. "/%postname%/" for "Post name", "" or null for the plain ?p=N default).', |
| 596 | 'category' => 'atarim', |
| 597 | 'input_schema' => [ |
| 598 | 'type' => 'object', |
| 599 | 'properties' => new \stdClass(), |
| 600 | 'additionalProperties' => false, |
| 601 | ], |
| 602 | 'output_schema' => [ |
| 603 | 'type' => 'object', |
| 604 | 'properties' => [ |
| 605 | 'permalink_structure' => [ 'type' => 'string' ], |
| 606 | 'category_base' => [ 'type' => 'string' ], |
| 607 | 'tag_base' => [ 'type' => 'string' ], |
| 608 | ], |
| 609 | 'required' => [ 'permalink_structure' ], |
| 610 | ], |
| 611 | 'execute_callback' => function( $input = [] ) { |
| 612 | return [ |
| 613 | 'permalink_structure' => (string) get_option( 'permalink_structure', '' ), |
| 614 | 'category_base' => (string) get_option( 'category_base', '' ), |
| 615 | 'tag_base' => (string) get_option( 'tag_base', '' ), |
| 616 | ]; |
| 617 | }, |
| 618 | 'permission_callback' => function() { |
| 619 | return current_user_can( 'manage_options' ); |
| 620 | }, |
| 621 | 'meta' => [ |
| 622 | 'mcp' => [ 'public' => true, 'type' => 'tool' ], |
| 623 | 'annotations' => [ 'readonly' => true, 'destructive' => false, 'idempotent' => true ], |
| 624 | ], |
| 625 | ] ); |
| 626 | |
| 627 | // ---- update-permalink-settings ---- |
| 628 | wp_register_ability( 'atarim/update-permalink-settings', [ |
| 629 | 'label' => 'Update Permalink Settings', |
| 630 | 'description' => 'Updates the permalink structure and optionally the category / tag URL bases. Flushes the rewrite rules cache so the new structure takes effect immediately. WARNING: changing permalink_structure on a public site without redirects can break all existing post URLs and tank SEO. Use carefully.', |
| 631 | 'category' => 'atarim', |
| 632 | 'input_schema' => [ |
| 633 | 'type' => 'object', |
| 634 | 'properties' => [ |
| 635 | 'permalink_structure' => [ |
| 636 | 'type' => 'string', |
| 637 | 'description' => 'Permalink template. Common values: "" (plain ?p=N), "/%postname%/" (post name), "/%category%/%postname%/", "/%year%/%monthnum%/%postname%/". Must start with /.', |
| 638 | ], |
| 639 | 'category_base' => [ |
| 640 | 'type' => 'string', |
| 641 | 'description' => 'URL prefix for category archives. Empty string means "/category/" default.', |
| 642 | ], |
| 643 | 'tag_base' => [ |
| 644 | 'type' => 'string', |
| 645 | 'description' => 'URL prefix for tag archives. Empty string means "/tag/" default.', |
| 646 | ], |
| 647 | ], |
| 648 | 'additionalProperties' => false, |
| 649 | ], |
| 650 | 'output_schema' => [ |
| 651 | 'type' => 'object', |
| 652 | 'properties' => [ |
| 653 | 'success' => [ 'type' => 'boolean' ], |
| 654 | 'updated' => [ 'type' => 'array', 'items' => [ 'type' => 'string' ] ], |
| 655 | 'rewrite_rules_flushed' => [ 'type' => 'boolean' ], |
| 656 | 'message' => [ 'type' => 'string' ], |
| 657 | ], |
| 658 | 'required' => [ 'success', 'message' ], |
| 659 | ], |
| 660 | 'execute_callback' => function( $input = [] ) { |
| 661 | $updated = []; |
| 662 | |
| 663 | if ( array_key_exists( 'permalink_structure', $input ) ) { |
| 664 | $struct = (string) $input['permalink_structure']; |
| 665 | if ( $struct !== '' && substr( $struct, 0, 1 ) !== '/' ) { |
| 666 | return [ 'success' => false, 'updated' => [], 'rewrite_rules_flushed' => false, 'message' => 'permalink_structure must start with "/" (or be an empty string for the plain default).' ]; |
| 667 | } |
| 668 | update_option( 'permalink_structure', $struct ); |
| 669 | $updated[] = 'permalink_structure'; |
| 670 | } |
| 671 | if ( array_key_exists( 'category_base', $input ) ) { |
| 672 | update_option( 'category_base', sanitize_text_field( (string) $input['category_base'] ) ); |
| 673 | $updated[] = 'category_base'; |
| 674 | } |
| 675 | if ( array_key_exists( 'tag_base', $input ) ) { |
| 676 | update_option( 'tag_base', sanitize_text_field( (string) $input['tag_base'] ) ); |
| 677 | $updated[] = 'tag_base'; |
| 678 | } |
| 679 | |
| 680 | if ( empty( $updated ) ) { |
| 681 | return [ 'success' => false, 'updated' => [], 'rewrite_rules_flushed' => false, 'message' => 'No fields provided to update.' ]; |
| 682 | } |
| 683 | |
| 684 | // Flush rewrite rules so the new structure takes effect. |
| 685 | flush_rewrite_rules( false ); |
| 686 | |
| 687 | return [ |
| 688 | 'success' => true, |
| 689 | 'updated' => $updated, |
| 690 | 'rewrite_rules_flushed' => true, |
| 691 | 'message' => sprintf( 'Updated: %s. Rewrite rules flushed.', implode( ', ', $updated ) ), |
| 692 | ]; |
| 693 | }, |
| 694 | 'permission_callback' => function() { |
| 695 | return current_user_can( 'manage_options' ); |
| 696 | }, |
| 697 | 'meta' => [ |
| 698 | 'mcp' => [ 'public' => true, 'type' => 'tool' ], |
| 699 | 'annotations' => [ 'readonly' => false, 'destructive' => false, 'idempotent' => true ], |
| 700 | ], |
| 701 | ] ); |
| 702 | |
| 703 | // ---- get-media-settings ---- |
| 704 | wp_register_ability( 'atarim/get-media-settings', [ |
| 705 | 'label' => 'Get Media Settings', |
| 706 | 'description' => 'Returns the registered image sizes (thumbnail / medium / medium_large / large) plus the "organize uploads in month/year folders" flag. Image dimensions are no longer in the Settings → Media UI in newer WordPress but they still control how WordPress generates intermediate image sizes during media uploads.', |
| 707 | 'category' => 'atarim', |
| 708 | 'input_schema' => [ |
| 709 | 'type' => 'object', |
| 710 | 'properties' => new \stdClass(), |
| 711 | 'additionalProperties' => false, |
| 712 | ], |
| 713 | 'output_schema' => [ |
| 714 | 'type' => 'object', |
| 715 | 'properties' => [ |
| 716 | 'thumbnail_size_w' => [ 'type' => 'integer' ], |
| 717 | 'thumbnail_size_h' => [ 'type' => 'integer' ], |
| 718 | 'thumbnail_crop' => [ 'type' => 'boolean' ], |
| 719 | 'medium_size_w' => [ 'type' => 'integer' ], |
| 720 | 'medium_size_h' => [ 'type' => 'integer' ], |
| 721 | 'medium_large_size_w' => [ 'type' => 'integer' ], |
| 722 | 'medium_large_size_h' => [ 'type' => 'integer' ], |
| 723 | 'large_size_w' => [ 'type' => 'integer' ], |
| 724 | 'large_size_h' => [ 'type' => 'integer' ], |
| 725 | 'uploads_use_yearmonth_folders' => [ 'type' => 'boolean' ], |
| 726 | ], |
| 727 | 'required' => [ 'thumbnail_size_w', 'medium_size_w', 'large_size_w' ], |
| 728 | ], |
| 729 | 'execute_callback' => function( $input = [] ) { |
| 730 | return [ |
| 731 | 'thumbnail_size_w' => (int) get_option( 'thumbnail_size_w', 150 ), |
| 732 | 'thumbnail_size_h' => (int) get_option( 'thumbnail_size_h', 150 ), |
| 733 | 'thumbnail_crop' => (bool) get_option( 'thumbnail_crop', 1 ), |
| 734 | 'medium_size_w' => (int) get_option( 'medium_size_w', 300 ), |
| 735 | 'medium_size_h' => (int) get_option( 'medium_size_h', 300 ), |
| 736 | 'medium_large_size_w' => (int) get_option( 'medium_large_size_w', 768 ), |
| 737 | 'medium_large_size_h' => (int) get_option( 'medium_large_size_h', 0 ), |
| 738 | 'large_size_w' => (int) get_option( 'large_size_w', 1024 ), |
| 739 | 'large_size_h' => (int) get_option( 'large_size_h', 1024 ), |
| 740 | 'uploads_use_yearmonth_folders' => (bool) get_option( 'uploads_use_yearmonth_folders', 1 ), |
| 741 | ]; |
| 742 | }, |
| 743 | 'permission_callback' => function() { |
| 744 | return current_user_can( 'manage_options' ); |
| 745 | }, |
| 746 | 'meta' => [ |
| 747 | 'mcp' => [ 'public' => true, 'type' => 'tool' ], |
| 748 | 'annotations' => [ 'readonly' => true, 'destructive' => false, 'idempotent' => true ], |
| 749 | ], |
| 750 | ] ); |
| 751 | |
| 752 | // ---- update-media-settings ---- |
| 753 | wp_register_ability( 'atarim/update-media-settings', [ |
| 754 | 'label' => 'Update Media Settings', |
| 755 | 'description' => 'Updates registered image sizes and the upload folder structure flag. Changes only affect NEW uploads going forward — existing media keeps its current intermediate sizes. Use a regenerate-thumbnails workflow separately if you need to recompute sizes for existing images.', |
| 756 | 'category' => 'atarim', |
| 757 | 'input_schema' => [ |
| 758 | 'type' => 'object', |
| 759 | 'properties' => [ |
| 760 | 'thumbnail_size_w' => [ 'type' => 'integer', 'minimum' => 0, 'maximum' => 9999 ], |
| 761 | 'thumbnail_size_h' => [ 'type' => 'integer', 'minimum' => 0, 'maximum' => 9999 ], |
| 762 | 'thumbnail_crop' => [ 'type' => 'boolean' ], |
| 763 | 'medium_size_w' => [ 'type' => 'integer', 'minimum' => 0, 'maximum' => 9999 ], |
| 764 | 'medium_size_h' => [ 'type' => 'integer', 'minimum' => 0, 'maximum' => 9999 ], |
| 765 | 'medium_large_size_w' => [ 'type' => 'integer', 'minimum' => 0, 'maximum' => 9999 ], |
| 766 | 'medium_large_size_h' => [ 'type' => 'integer', 'minimum' => 0, 'maximum' => 9999 ], |
| 767 | 'large_size_w' => [ 'type' => 'integer', 'minimum' => 0, 'maximum' => 9999 ], |
| 768 | 'large_size_h' => [ 'type' => 'integer', 'minimum' => 0, 'maximum' => 9999 ], |
| 769 | 'uploads_use_yearmonth_folders' => [ 'type' => 'boolean' ], |
| 770 | ], |
| 771 | 'additionalProperties' => false, |
| 772 | ], |
| 773 | 'output_schema' => [ |
| 774 | 'type' => 'object', |
| 775 | 'properties' => [ |
| 776 | 'success' => [ 'type' => 'boolean' ], |
| 777 | 'updated' => [ 'type' => 'array', 'items' => [ 'type' => 'string' ] ], |
| 778 | 'message' => [ 'type' => 'string' ], |
| 779 | ], |
| 780 | 'required' => [ 'success', 'message' ], |
| 781 | ], |
| 782 | 'execute_callback' => function( $input = [] ) { |
| 783 | $updated = []; |
| 784 | $int_fields = [ 'thumbnail_size_w', 'thumbnail_size_h', 'medium_size_w', 'medium_size_h', 'medium_large_size_w', 'medium_large_size_h', 'large_size_w', 'large_size_h' ]; |
| 785 | $bool_fields = [ 'thumbnail_crop', 'uploads_use_yearmonth_folders' ]; |
| 786 | |
| 787 | foreach ( $int_fields as $field ) { |
| 788 | if ( array_key_exists( $field, $input ) ) { |
| 789 | update_option( $field, max( 0, (int) $input[ $field ] ) ); |
| 790 | $updated[] = $field; |
| 791 | } |
| 792 | } |
| 793 | foreach ( $bool_fields as $field ) { |
| 794 | if ( array_key_exists( $field, $input ) ) { |
| 795 | update_option( $field, $input[ $field ] ? 1 : 0 ); |
| 796 | $updated[] = $field; |
| 797 | } |
| 798 | } |
| 799 | |
| 800 | if ( empty( $updated ) ) { |
| 801 | return [ 'success' => false, 'updated' => [], 'message' => 'No fields provided to update.' ]; |
| 802 | } |
| 803 | |
| 804 | return [ |
| 805 | 'success' => true, |
| 806 | 'updated' => $updated, |
| 807 | 'message' => sprintf( 'Updated: %s. Affects new uploads only; existing media retains its current intermediate sizes.', implode( ', ', $updated ) ), |
| 808 | ]; |
| 809 | }, |
| 810 | 'permission_callback' => function() { |
| 811 | return current_user_can( 'manage_options' ); |
| 812 | }, |
| 813 | 'meta' => [ |
| 814 | 'mcp' => [ 'public' => true, 'type' => 'tool' ], |
| 815 | 'annotations' => [ 'readonly' => false, 'destructive' => false, 'idempotent' => true ], |
| 816 | ], |
| 817 | ] ); |
| 818 | |
| 819 | // ---- get-robots-txt ---- |
| 820 | wp_register_ability( 'atarim/get-robots-txt', [ |
| 821 | 'label' => 'Get robots.txt', |
| 822 | 'description' => 'Returns the live robots.txt content as WordPress generates it — starting from the default (Allow / Disallow depending on the search engine visibility setting) and running through any robots_txt filters added by SEO plugins. This is what search engines actually see when they request /robots.txt. Write support is not in this round; use an SEO plugin\'s robots.txt editor in the meantime.', |
| 823 | 'category' => 'atarim', |
| 824 | 'input_schema' => [ |
| 825 | 'type' => 'object', |
| 826 | 'properties' => new \stdClass(), |
| 827 | 'additionalProperties' => false, |
| 828 | ], |
| 829 | 'output_schema' => [ |
| 830 | 'type' => 'object', |
| 831 | 'properties' => [ |
| 832 | 'content' => [ 'type' => 'string' ], |
| 833 | 'search_engine_visible' => [ 'type' => 'boolean' ], |
| 834 | 'byte_length' => [ 'type' => 'integer' ], |
| 835 | 'physical_file_exists' => [ 'type' => 'boolean' ], |
| 836 | ], |
| 837 | 'required' => [ 'content' ], |
| 838 | ], |
| 839 | 'execute_callback' => function( $input = [] ) { |
| 840 | // Build the default WordPress would emit, then run it through the robots_txt filter |
| 841 | // so any contributions from SEO plugins (Yoast, Rank Math) are captured. |
| 842 | $public = (bool) get_option( 'blog_public', 1 ); |
| 843 | |
| 844 | $home_path = parse_url( home_url(), PHP_URL_PATH ); |
| 845 | $site_url = parse_url( site_url() ); |
| 846 | $site_path = empty( $site_url['path'] ) ? '/' : trailingslashit( $site_url['path'] ); |
| 847 | |
| 848 | if ( ! $public ) { |
| 849 | $output = "User-agent: *\n"; |
| 850 | $output .= "Disallow: /\n"; |
| 851 | } else { |
| 852 | $output = "User-agent: *\n"; |
| 853 | $output .= "Disallow: " . $site_path . "wp-admin/\n"; |
| 854 | $output .= "Allow: " . $site_path . "wp-admin/admin-ajax.php\n"; |
| 855 | } |
| 856 | |
| 857 | // Run through the canonical robots_txt filter so SEO plugins contribute. |
| 858 | $content = apply_filters( 'robots_txt', $output, $public ); |
| 859 | |
| 860 | $physical_path = ABSPATH . 'robots.txt'; |
| 861 | |
| 862 | return [ |
| 863 | 'content' => (string) $content, |
| 864 | 'search_engine_visible' => $public, |
| 865 | 'byte_length' => strlen( (string) $content ), |
| 866 | // If a physical robots.txt file is on disk, it OVERRIDES WordPress's dynamic version. |
| 867 | // Surfacing this so the AI can warn the user if expected dynamic content isn't showing up. |
| 868 | 'physical_file_exists' => file_exists( $physical_path ), |
| 869 | ]; |
| 870 | }, |
| 871 | 'permission_callback' => function() { |
| 872 | return current_user_can( 'manage_options' ); |
| 873 | }, |
| 874 | 'meta' => [ |
| 875 | 'mcp' => [ 'public' => true, 'type' => 'tool' ], |
| 876 | 'annotations' => [ 'readonly' => true, 'destructive' => false, 'idempotent' => true ], |
| 877 | ], |
| 878 | ] ); |
| 879 | |
| 880 | // ---- get-htaccess ---- |
| 881 | wp_register_ability( 'atarim/get-htaccess', [ |
| 882 | 'label' => 'Get .htaccess', |
| 883 | 'description' => 'Reads the .htaccess file at the WordPress root, if present. Many sites (especially nginx-hosted ones) have no .htaccess at all — returns exists: false in that case. Write support is intentionally not exposed; .htaccess changes are a hosting-layer concern and the risk of breaking the site with a bad rule is too high for the AI to do unattended.', |
| 884 | 'category' => 'atarim', |
| 885 | 'input_schema' => [ |
| 886 | 'type' => 'object', |
| 887 | 'properties' => new \stdClass(), |
| 888 | 'additionalProperties' => false, |
| 889 | ], |
| 890 | 'output_schema' => [ |
| 891 | 'type' => 'object', |
| 892 | 'properties' => [ |
| 893 | 'exists' => [ 'type' => 'boolean' ], |
| 894 | 'readable' => [ 'type' => 'boolean' ], |
| 895 | 'path' => [ 'type' => 'string' ], |
| 896 | 'content' => [ 'type' => 'string' ], |
| 897 | 'byte_length' => [ 'type' => 'integer' ], |
| 898 | 'message' => [ 'type' => 'string' ], |
| 899 | ], |
| 900 | 'required' => [ 'exists', 'path' ], |
| 901 | ], |
| 902 | 'execute_callback' => function( $input = [] ) { |
| 903 | $path = ABSPATH . '.htaccess'; |
| 904 | |
| 905 | if ( ! file_exists( $path ) ) { |
| 906 | return [ |
| 907 | 'exists' => false, |
| 908 | 'readable' => false, |
| 909 | 'path' => $path, |
| 910 | 'content' => '', |
| 911 | 'byte_length' => 0, |
| 912 | 'message' => 'No .htaccess file found at the WordPress root. The site is likely running on nginx, or the host does not use .htaccess.', |
| 913 | ]; |
| 914 | } |
| 915 | |
| 916 | if ( ! is_readable( $path ) ) { |
| 917 | return [ |
| 918 | 'exists' => true, |
| 919 | 'readable' => false, |
| 920 | 'path' => $path, |
| 921 | 'content' => '', |
| 922 | 'byte_length' => (int) @filesize( $path ), |
| 923 | 'message' => '.htaccess exists but is not readable by the web server user. Check filesystem permissions.', |
| 924 | ]; |
| 925 | } |
| 926 | |
| 927 | $content = (string) file_get_contents( $path ); |
| 928 | return [ |
| 929 | 'exists' => true, |
| 930 | 'readable' => true, |
| 931 | 'path' => $path, |
| 932 | 'content' => $content, |
| 933 | 'byte_length' => strlen( $content ), |
| 934 | 'message' => sprintf( 'Read %d byte(s) from .htaccess.', strlen( $content ) ), |
| 935 | ]; |
| 936 | }, |
| 937 | 'permission_callback' => function() { |
| 938 | return current_user_can( 'manage_options' ); |
| 939 | }, |
| 940 | 'meta' => [ |
| 941 | 'mcp' => [ 'public' => true, 'type' => 'tool' ], |
| 942 | 'annotations' => [ 'readonly' => true, 'destructive' => false, 'idempotent' => true ], |
| 943 | ], |
| 944 | ] ); |
| 945 | } |
| 946 | } |
| 947 |