PluginProbe ʕ •ᴥ•ʔ
Atarim – AI Agency for WordPress: Edit Pages, Fix Code, Update Plugins, SEO & Client Feedback / trunk
Atarim – AI Agency for WordPress: Edit Pages, Fix Code, Update Plugins, SEO & Client Feedback vtrunk
5.1.3 5.1.2 5.1.1 5.1 5.0 trunk 3.10 3.11 3.12 3.13 3.14 3.15 3.16 3.17 3.18 3.19 3.2.0 3.2.1 3.22 3.22.1 3.22.2 3.22.3 3.22.4 3.22.5 3.22.6 3.3.0 3.3.1 3.3.2 3.3.2.1 3.3.2.2 3.3.3 3.30 3.31 3.32 3.4 3.4.1 3.4.3 3.4.4 3.5 3.5.1 3.6 3.6.1 3.7 3.8 3.9 3.9.1 3.9.2 3.9.3 3.9.4 3.9.6 3.9.6.1 4.0 4.0.1 4.0.2 4.0.3 4.0.4 4.0.5 4.0.6 4.0.7 4.0.8 4.0.9 4.1.0 4.1.1 4.1.2 4.1.3 4.2 4.2.1 4.2.2 4.3 4.3.1 4.3.2 4.3.3 4.3.4 4.3.5 4.4
atarim-visual-collaboration / doit / abilities / class-avcf-abilities-settings.php
atarim-visual-collaboration / doit / abilities Last commit date
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