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-content.php
atarim-visual-collaboration / doit / abilities Last commit date
class-avcf-abilities-base.php 4 weeks ago class-avcf-abilities-block-navigation.php 4 weeks ago class-avcf-abilities-cache.php 4 weeks ago class-avcf-abilities-content.php 2 weeks ago class-avcf-abilities-core.php 4 days ago class-avcf-abilities-execute-php.php 2 weeks ago class-avcf-abilities-global-styles.php 4 weeks ago class-avcf-abilities-gutenberg.php 2 weeks ago class-avcf-abilities-media.php 2 weeks ago class-avcf-abilities-metadata.php 2 weeks ago class-avcf-abilities-navigation.php 4 weeks ago class-avcf-abilities-patterns.php 4 weeks ago class-avcf-abilities-plugins.php 4 days ago class-avcf-abilities-readonly.php 2 weeks ago class-avcf-abilities-settings.php 4 weeks ago class-avcf-abilities-taxonomies.php 4 weeks ago class-avcf-abilities-templates.php 4 weeks ago class-avcf-abilities-theme-files.php 2 weeks ago class-avcf-abilities-themes.php 4 days ago class-avcf-abilities-users.php 4 weeks ago class-avcf-abilities-wp-cli.php 4 days ago
class-avcf-abilities-content.php
2124 lines
1 <?php
2 /**
3 * Content (posts, pages, custom post types) MCP abilities.
4 *
5 * Registers Atarim/* abilities for reading and managing post objects of any
6 * post type — built-in (post, page) or custom. One ability per verb; the
7 * post_type parameter selects which type to operate on.
8 *
9 * Exposed abilities:
10 * atarim/list-post-types Discover available post type slugs.
11 * atarim/list-content Query posts with rich filters + pagination.
12 * atarim/get-content Read a single post with full body, taxonomies, meta.
13 * atarim/create-content Create a post/page/CPT item.
14 * atarim/update-content Update an existing post/page/CPT item.
15 * atarim/bulk-update-content Update one field across many posts in one call.
16 * atarim/delete-content Trash or permanently delete an item.
17 * atarim/list-revisions List revision history for a post.
18 * atarim/restore-revision Restore a post to a prior revision.
19 *
20 * Note: ability names registered here must also be added to the $tools array
21 * in doit/class-avcf-mcp.php::avcf_mcp_setup_server() to be exposed by the
22 * MCP server.
23 *
24 * @package atarim-visual-collaboration
25 */
26
27 if ( ! defined('ABSPATH') ) {
28 exit;
29 }
30
31 class AVCF_Abilities_Content extends AVCF_Abilities_Base {
32
33 /**
34 * Post types that must never be created or updated through the generic
35 * content abilities. These are internal / structural types WordPress
36 * stores as posts but which own dedicated write pipelines (Customizer,
37 * Site Editor, block/nav editors) that apply sanitisation and side
38 * effects the generic post write does not. Writing raw block markup into
39 * e.g. custom_css would store invalid CSS verbatim.
40 *
41 * @return string[]
42 */
43 private function avcf_write_protected_post_types() {
44 return [
45 'attachment', 'revision', 'nav_menu_item', 'custom_css',
46 'customize_changeset', 'oembed_cache', 'user_request', 'wp_block',
47 'wp_template', 'wp_template_part', 'wp_global_styles', 'wp_navigation',
48 ];
49 }
50
51 /**
52 * Redirect hint for a write-protected post type, so the refusal points the
53 * agent at the correct dedicated ability where one exists.
54 *
55 * @param string $post_type
56 * @return string
57 */
58 private function avcf_write_protected_hint( $post_type ) {
59 $map = [
60 'custom_css' => ' Use atarim/set-additional-css to change the theme Additional CSS.',
61 ];
62 return isset( $map[ $post_type ] ) ? $map[ $post_type ] : '';
63 }
64
65 /**
66 * Register all content abilities.
67 * Called from AVCF_MCP::avcf_mcp_register_abilities() on wp_abilities_api_init.
68 */
69 public function register() {
70
71 // ---- list-post-types ----
72 wp_register_ability( 'atarim/list-post-types', [
73 'label' => 'List Post Types',
74 'description' => 'Returns all available post types on the site, including built-in (post, page) and custom post types. Use the returned slugs as the post_type parameter for list-content and create-content.',
75 'category' => 'atarim',
76 'input_schema' => [
77 'type' => 'object',
78 'properties' => [
79 'public_only' => [
80 'type' => 'boolean',
81 'description' => 'If true, return only public post types. Defaults to true.',
82 'default' => true,
83 ],
84 ],
85 'additionalProperties' => false,
86 ],
87 'output_schema' => [
88 'type' => 'object',
89 'properties' => [
90 'total' => [ 'type' => 'integer' ],
91 'post_types' => [
92 'type' => 'array',
93 'items' => [
94 'type' => 'object',
95 'properties' => [
96 'slug' => [ 'type' => 'string' ],
97 'label' => [ 'type' => 'string' ],
98 'singular' => [ 'type' => 'string' ],
99 'description' => [ 'type' => 'string' ],
100 'public' => [ 'type' => 'boolean' ],
101 'hierarchical' => [ 'type' => 'boolean' ],
102 'built_in' => [ 'type' => 'boolean' ],
103 'supports' => [ 'type' => 'array', 'items' => [ 'type' => 'string' ] ],
104 ],
105 ],
106 ],
107 ],
108 'required' => [ 'total', 'post_types' ],
109 ],
110 'execute_callback' => function( $input = [] ) {
111 $public_only = isset( $input['public_only'] ) ? (bool) $input['public_only'] : true;
112
113 $args = $public_only ? [ 'public' => true ] : [];
114 $types = get_post_types( $args, 'objects' );
115
116 // Exclude attachment by default — it's "public" but rarely what a caller means.
117 unset( $types['attachment'] );
118
119 $result = [];
120 foreach ( $types as $slug => $obj ) {
121 $result[] = [
122 'slug' => $slug,
123 'label' => isset( $obj->labels->name ) ? $obj->labels->name : ( isset( $obj->label ) ? $obj->label : $slug ),
124 'singular' => isset( $obj->labels->singular_name ) ? $obj->labels->singular_name : '',
125 'description' => isset( $obj->description ) ? $obj->description : '',
126 'public' => (bool) $obj->public,
127 'hierarchical' => (bool) $obj->hierarchical,
128 'built_in' => (bool) $obj->_builtin,
129 'supports' => array_keys( get_all_post_type_supports( $slug ) ),
130 ];
131 }
132
133 return [
134 'total' => count( $result ),
135 'post_types' => $result,
136 ];
137 },
138 'permission_callback' => function() {
139 return current_user_can( 'edit_posts' );
140 },
141 'meta' => [
142 'mcp' => [ 'public' => true, 'type' => 'tool' ],
143 'annotations' => [
144 'readonly' => true,
145 'destructive' => false,
146 'idempotent' => true,
147 ],
148 ],
149 ] );
150
151 // ---- list-content ----
152 wp_register_ability( 'atarim/list-content', [
153 'label' => 'List Content',
154 'description' => 'Query posts, pages, or custom post types with rich filters: status, author, date range, taxonomy, custom field (meta) key/value, free-text search, plus ordering and pagination. Each returned item includes title, slug, status, dates, excerpt, and a content_preview (first ~200 chars of body, HTML/blocks stripped). Pass include_content: true to also return full post bodies — use sparingly, response size grows. Use list-post-types to discover available post_type values.',
155 'category' => 'atarim',
156 'input_schema' => [
157 'type' => 'object',
158 'properties' => [
159 'post_type' => [
160 'type' => 'string',
161 'description' => 'Post type slug (e.g. "post", "page", "product"). Defaults to "post".',
162 'default' => 'post',
163 'minLength' => 1,
164 ],
165 'status' => [
166 'type' => [ 'string', 'array' ],
167 'description' => 'Filter by post status. Single value or array. Omit for all non-trashed. Accepted values: publish, draft, pending, private, future, trash.',
168 'enum' => [ 'publish', 'draft', 'pending', 'private', 'future', 'trash' ],
169 ],
170 'author' => [
171 'type' => 'integer',
172 'description' => 'Filter by author user ID.',
173 'minimum' => 1,
174 ],
175 'date_from' => [
176 'type' => 'string',
177 'description' => 'Only items with publish date on or after this date. ISO 8601 or strtotime()-parseable.',
178 ],
179 'date_to' => [
180 'type' => 'string',
181 'description' => 'Only items with publish date on or before this date. ISO 8601 or strtotime()-parseable.',
182 ],
183 'search' => [
184 'type' => 'string',
185 'description' => 'Free-text keyword search across title and content (WordPress default search behaviour).',
186 'minLength' => 1,
187 ],
188 'taxonomy' => [
189 'type' => 'string',
190 'description' => 'Taxonomy slug to filter by (e.g. "category", "post_tag", or a custom taxonomy). Use with "terms" parameter. The taxonomy must apply to the post_type.',
191 'minLength' => 1,
192 ],
193 'terms' => [
194 'type' => 'array',
195 'description' => 'Array of term slugs to match (OR semantics — items with any of these terms). Requires "taxonomy".',
196 'items' => [ 'type' => 'string' ],
197 'minItems' => 1,
198 ],
199 'meta_key' => [
200 'type' => 'string',
201 'description' => 'Custom field key to filter by. Pair with meta_value and optionally meta_compare.',
202 'minLength' => 1,
203 ],
204 'meta_value' => [
205 'type' => 'string',
206 'description' => 'Custom field value to match.',
207 ],
208 'meta_compare' => [
209 'type' => 'string',
210 'description' => 'How to compare meta_value. Defaults to "=". Supports the safe subset of WP_Query meta_compare operators.',
211 'enum' => [ '=', '!=', '>', '>=', '<', '<=', 'LIKE', 'NOT LIKE', 'EXISTS', 'NOT EXISTS' ],
212 'default' => '=',
213 ],
214 'orderby' => [
215 'type' => 'string',
216 'description' => 'Field to sort by.',
217 'enum' => [ 'date', 'modified', 'title', 'menu_order', 'ID', 'author', 'rand' ],
218 'default' => 'date',
219 ],
220 'order' => [
221 'type' => 'string',
222 'description' => 'Sort direction.',
223 'enum' => [ 'ASC', 'DESC' ],
224 'default' => 'DESC',
225 ],
226 'limit' => [
227 'type' => 'integer',
228 'description' => 'Max items per page. -1 returns all (use carefully). Defaults to 20.',
229 'default' => 20,
230 'minimum' => -1,
231 ],
232 'offset' => [
233 'type' => 'integer',
234 'description' => 'Skip this many items before returning results. For pagination.',
235 'default' => 0,
236 'minimum' => 0,
237 ],
238 'include_content' => [
239 'type' => 'boolean',
240 'description' => 'Include the full post body in each item under "content". Defaults to false — only a short content_preview is returned. Set true when the caller actually needs full bodies; responses can grow large.',
241 'default' => false,
242 ],
243 ],
244 'additionalProperties' => false,
245 ],
246 'output_schema' => [
247 'type' => 'object',
248 'properties' => [
249 'total' => [ 'type' => 'integer' ],
250 'returned' => [ 'type' => 'integer' ],
251 'offset' => [ 'type' => 'integer' ],
252 'post_type' => [ 'type' => 'string' ],
253 'items' => [
254 'type' => 'array',
255 'items' => [
256 'type' => 'object',
257 'properties' => [
258 'id' => [ 'type' => 'integer' ],
259 'title' => [ 'type' => 'string' ],
260 'slug' => [ 'type' => 'string' ],
261 'status' => [ 'type' => 'string' ],
262 'post_type' => [ 'type' => 'string' ],
263 'url' => [ 'type' => 'string' ],
264 'author' => [ 'type' => 'integer' ],
265 'parent' => [ 'type' => 'integer' ],
266 'date' => [ 'type' => 'string' ],
267 'created' => [ 'type' => 'string' ],
268 'modified' => [ 'type' => 'string' ],
269 'excerpt' => [ 'type' => 'string' ],
270 'content_preview' => [ 'type' => 'string' ],
271 'content' => [ 'type' => 'string' ],
272 ],
273 ],
274 ],
275 ],
276 'required' => [ 'total', 'returned', 'post_type', 'items' ],
277 ],
278 'execute_callback' => function( $input = [] ) {
279 $post_type = isset( $input['post_type'] ) ? sanitize_key( $input['post_type'] ) : 'post';
280
281 if ( ! post_type_exists( $post_type ) ) {
282 return [
283 'total' => 0,
284 'returned' => 0,
285 'offset' => 0,
286 'post_type' => $post_type,
287 'items' => [],
288 ];
289 }
290
291 $limit = isset( $input['limit'] ) ? (int) $input['limit'] : 20;
292 $offset = isset( $input['offset'] ) ? max( 0, (int) $input['offset'] ) : 0;
293
294 $args = [
295 'post_type' => $post_type,
296 'posts_per_page' => $limit,
297 'offset' => $offset,
298 'orderby' => isset( $input['orderby'] ) ? sanitize_key( $input['orderby'] ) : 'date',
299 'order' => ( isset( $input['order'] ) && strtoupper( $input['order'] ) === 'ASC' ) ? 'ASC' : 'DESC',
300 // suppress_filters off so caching/translation plugins still apply.
301 ];
302
303 if ( ! empty( $input['status'] ) ) {
304 $args['post_status'] = $input['status'];
305 } else {
306 $args['post_status'] = [ 'publish', 'draft', 'pending', 'private', 'future' ];
307 }
308
309 if ( isset( $input['author'] ) ) {
310 $args['author'] = (int) $input['author'];
311 }
312
313 if ( isset( $input['search'] ) && $input['search'] !== '' ) {
314 $args['s'] = (string) $input['search'];
315 }
316
317 // Date range — WP_Query accepts a date_query array.
318 if ( isset( $input['date_from'] ) || isset( $input['date_to'] ) ) {
319 $date_query = [];
320 if ( isset( $input['date_from'] ) && $input['date_from'] !== '' ) {
321 list( $local_from, , $err_from ) = $this->avcf_normalize_post_date( (string) $input['date_from'] );
322 if ( $err_from !== null ) {
323 return [
324 'total' => 0,
325 'returned' => 0,
326 'offset' => $offset,
327 'post_type' => $post_type,
328 'items' => [],
329 'message' => 'date_from invalid: ' . $err_from,
330 ];
331 }
332 $date_query['after'] = $local_from;
333 }
334 if ( isset( $input['date_to'] ) && $input['date_to'] !== '' ) {
335 list( $local_to, , $err_to ) = $this->avcf_normalize_post_date( (string) $input['date_to'] );
336 if ( $err_to !== null ) {
337 return [
338 'total' => 0,
339 'returned' => 0,
340 'offset' => $offset,
341 'post_type' => $post_type,
342 'items' => [],
343 'message' => 'date_to invalid: ' . $err_to,
344 ];
345 }
346 $date_query['before'] = $local_to;
347 }
348 $date_query['inclusive'] = true;
349 $args['date_query'] = [ $date_query ];
350 }
351
352 // Taxonomy filter — single taxonomy + array of term slugs, OR semantics.
353 if ( ! empty( $input['taxonomy'] ) && ! empty( $input['terms'] ) ) {
354 $tax = sanitize_key( (string) $input['taxonomy'] );
355 if ( ! taxonomy_exists( $tax ) ) {
356 return [
357 'total' => 0,
358 'returned' => 0,
359 'offset' => $offset,
360 'post_type' => $post_type,
361 'items' => [],
362 'message' => sprintf( 'Taxonomy "%s" does not exist on this site.', $tax ),
363 ];
364 }
365 $terms = array_map( 'sanitize_title', (array) $input['terms'] );
366 $args['tax_query'] = [
367 [
368 'taxonomy' => $tax,
369 'field' => 'slug',
370 'terms' => $terms,
371 'operator' => 'IN',
372 ],
373 ];
374 }
375
376 // Meta filter — single key/value/compare. EXISTS / NOT EXISTS don't need a value.
377 if ( ! empty( $input['meta_key'] ) ) {
378 $compare = isset( $input['meta_compare'] ) ? (string) $input['meta_compare'] : '=';
379 $allowed_compare = [ '=', '!=', '>', '>=', '<', '<=', 'LIKE', 'NOT LIKE', 'EXISTS', 'NOT EXISTS' ];
380 if ( ! in_array( $compare, $allowed_compare, true ) ) {
381 $compare = '=';
382 }
383 $meta_clause = [
384 'key' => (string) $input['meta_key'],
385 'compare' => $compare,
386 ];
387 if ( $compare !== 'EXISTS' && $compare !== 'NOT EXISTS' ) {
388 $meta_clause['value'] = isset( $input['meta_value'] ) ? (string) $input['meta_value'] : '';
389 }
390 $args['meta_query'] = [ $meta_clause ];
391 }
392
393 $include_content = ! empty( $input['include_content'] );
394 $query = new \WP_Query( $args );
395
396 $items = [];
397 foreach ( $query->posts as $post ) {
398 $excerpt = $post->post_excerpt;
399 if ( $excerpt === '' && $post->post_content !== '' ) {
400 // Generate a short excerpt from content when one isn't authored.
401 $excerpt = wp_trim_words( wp_strip_all_tags( strip_shortcodes( $post->post_content ) ), 30, '' );
402 }
403
404 // content_preview: ~200 chars of plain text from the body, ellipsis if truncated.
405 $stripped = trim( wp_strip_all_tags( strip_shortcodes( $post->post_content ) ) );
406 $stripped = preg_replace( '/\s+/', ' ', $stripped );
407 $content_preview = ( strlen( $stripped ) > 200 )
408 ? substr( $stripped, 0, 200 ) . ''
409 : $stripped;
410
411 $item = [
412 'id' => $post->ID,
413 'title' => $post->post_title,
414 'slug' => $post->post_name,
415 'status' => $post->post_status,
416 'post_type' => $post->post_type,
417 'url' => get_permalink( $post->ID ),
418 'author' => (int) $post->post_author,
419 'parent' => (int) $post->post_parent,
420 'date' => $post->post_date,
421 'created' => $post->post_date_gmt,
422 'modified' => $post->post_modified_gmt,
423 'excerpt' => $excerpt,
424 'content_preview' => $content_preview,
425 ];
426
427 if ( $include_content ) {
428 $item['content'] = $post->post_content;
429 }
430
431 $items[] = $item;
432 }
433
434 $total = (int) $query->found_posts;
435 $has_more = $limit > 0 && ( $offset + count( $items ) ) < $total;
436
437 return [
438 'total' => $total,
439 'returned' => count( $items ),
440 'offset' => $offset,
441 'has_more' => $has_more,
442 'post_type' => $post_type,
443 'items' => $items,
444 'truncated_notice' => $has_more
445 ? sprintf(
446 'Showing %d of %d. Increase limit or page with offset=%d before concluding anything about the full set.',
447 count( $items ),
448 $total,
449 $offset + count( $items )
450 )
451 : null,
452 ];
453 },
454 'permission_callback' => function() {
455 return current_user_can( 'edit_posts' );
456 },
457 'meta' => [
458 'mcp' => [ 'public' => true, 'type' => 'tool' ],
459 'annotations' => [
460 'readonly' => true,
461 'destructive' => false,
462 'idempotent' => true,
463 ],
464 ],
465 ] );
466
467 // ---- get-content ----
468 wp_register_ability( 'atarim/get-content', [
469 'label' => 'Get Content',
470 'description' => 'Returns full detail for a single post, page, or custom post type item — including the post body, excerpt, dates, author, parent, featured image, comment/ping status. Optional include flags add taxonomies (categories, tags, custom taxonomies) and selected meta fields. Use list-content to discover IDs.',
471 'category' => 'atarim',
472 'input_schema' => [
473 'type' => 'object',
474 'properties' => [
475 'id' => [
476 'type' => 'integer',
477 'description' => 'Post ID.',
478 'minimum' => 1,
479 ],
480 'include_taxonomies' => [
481 'type' => 'boolean',
482 'description' => 'Include all assigned taxonomy terms (categories, tags, custom taxonomies) for the post.',
483 'default' => false,
484 ],
485 'include_meta_keys' => [
486 'type' => 'array',
487 'description' => 'List of specific meta keys to read. Omit or empty array to skip meta. Keys beginning with "_" (private/internal meta) are excluded for safety even if requested.',
488 'items' => [ 'type' => 'string' ],
489 ],
490 ],
491 'required' => [ 'id' ],
492 'additionalProperties' => false,
493 ],
494 'output_schema' => [
495 'type' => 'object',
496 'properties' => [
497 'success' => [ 'type' => 'boolean' ],
498 'id' => [ 'type' => 'integer' ],
499 'title' => [ 'type' => 'string' ],
500 'slug' => [ 'type' => 'string' ],
501 'status' => [ 'type' => 'string' ],
502 'post_type' => [ 'type' => 'string' ],
503 'url' => [ 'type' => 'string' ],
504 'content' => [ 'type' => 'string' ],
505 'excerpt' => [ 'type' => 'string' ],
506 'date' => [ 'type' => 'string' ],
507 'created' => [ 'type' => 'string' ],
508 'modified' => [ 'type' => 'string' ],
509 'author' => [ 'type' => 'integer' ],
510 'parent' => [ 'type' => 'integer' ],
511 'featured_media' => [ 'type' => 'integer' ],
512 'comment_status' => [ 'type' => 'string' ],
513 'ping_status' => [ 'type' => 'string' ],
514 'taxonomies' => [ 'type' => 'object' ],
515 'meta' => [ 'type' => 'object' ],
516 'message' => [ 'type' => 'string' ],
517 ],
518 'required' => [ 'success' ],
519 ],
520 'execute_callback' => function( $input = [] ) {
521 $id = isset( $input['id'] ) ? (int) $input['id'] : 0;
522 if ( $id <= 0 ) {
523 return [ 'success' => false, 'message' => 'id is required and must be a positive integer.' ];
524 }
525
526 $post = get_post( $id );
527 if ( ! $post ) {
528 return [ 'success' => false, 'message' => sprintf( 'Post %d not found.', $id ) ];
529 }
530
531 $pt_obj = get_post_type_object( $post->post_type );
532 if ( $pt_obj && ! current_user_can( $pt_obj->cap->read_post, $id ) ) {
533 return [ 'success' => false, 'message' => sprintf( 'You do not have permission to read this %s.', $post->post_type ) ];
534 }
535
536 $result = [
537 'success' => true,
538 'id' => $post->ID,
539 'title' => $post->post_title,
540 'slug' => $post->post_name,
541 'status' => $post->post_status,
542 'post_type' => $post->post_type,
543 'url' => get_permalink( $post->ID ),
544 'content' => $post->post_content,
545 'excerpt' => $post->post_excerpt,
546 'date' => $post->post_date,
547 'created' => $post->post_date_gmt,
548 'modified' => $post->post_modified_gmt,
549 'author' => (int) $post->post_author,
550 'parent' => (int) $post->post_parent,
551 'featured_media' => (int) get_post_thumbnail_id( $post->ID ),
552 'comment_status' => $post->comment_status,
553 'ping_status' => $post->ping_status,
554 'message' => 'OK.',
555 ];
556
557 if ( ! empty( $input['include_taxonomies'] ) ) {
558 $taxonomies = get_object_taxonomies( $post->post_type, 'names' );
559 $tax_assignments = [];
560 foreach ( $taxonomies as $tax ) {
561 $terms = get_the_terms( $post->ID, $tax );
562 if ( is_wp_error( $terms ) || empty( $terms ) ) {
563 $tax_assignments[ $tax ] = [];
564 continue;
565 }
566 $tax_assignments[ $tax ] = array_map(
567 function( $t ) {
568 return [
569 'term_id' => (int) $t->term_id,
570 'slug' => $t->slug,
571 'name' => $t->name,
572 ];
573 },
574 $terms
575 );
576 }
577 $result['taxonomies'] = $tax_assignments;
578 }
579
580 if ( ! empty( $input['include_meta_keys'] ) && is_array( $input['include_meta_keys'] ) ) {
581 $meta = [];
582 foreach ( $input['include_meta_keys'] as $key ) {
583 $key = (string) $key;
584 // Skip private/internal meta even if explicitly requested.
585 if ( $key === '' || strpos( $key, '_' ) === 0 ) {
586 continue;
587 }
588 $value = get_post_meta( $post->ID, $key, true );
589 $meta[ $key ] = $value;
590 }
591 $result['meta'] = $meta;
592 }
593
594 return $result;
595 },
596 'permission_callback' => function() {
597 return current_user_can( 'edit_posts' );
598 },
599 'meta' => [
600 'mcp' => [ 'public' => true, 'type' => 'tool' ],
601 'annotations' => [
602 'readonly' => true,
603 'destructive' => false,
604 'idempotent' => true,
605 ],
606 ],
607 ] );
608
609 // ---- create-content ----
610 wp_register_ability( 'atarim/create-content', [
611 'label' => 'Create Content',
612 'description' => 'Creates a new post, page, or custom post type item. Required: post_type and title. All other fields are optional — WordPress auto-generates the slug from the title if omitted, and status defaults to "draft". The content body can be supplied inline (content — plain text, raw HTML, or Gutenberg block markup) or pulled from a URL (content_url); see content_format to control processing. Use list-post-types to discover available post_type values.',
613 'category' => 'atarim',
614 'input_schema' => [
615 'type' => 'object',
616 'properties' => [
617 'post_type' => [
618 'type' => 'string',
619 'description' => 'Post type slug (e.g. "post", "page", or a custom slug). Defaults to "post" — mirrors list-content, so a call that omits it, or sends it empty, is no longer rejected by the schema validator before this plugin can explain why.',
620 'default' => 'post',
621 ],
622 'title' => [
623 'type' => 'string',
624 'description' => 'Post title.',
625 'minLength' => 1,
626 ],
627 'slug' => [
628 'type' => 'string',
629 'description' => 'Desired URL slug. Omit to let WordPress generate one from the title. WordPress auto-suffixes on conflict (slug-2, slug-3).',
630 'minLength' => 1,
631 ],
632 'status' => [
633 'type' => 'string',
634 'description' => 'Publish status. Defaults to "draft". Use "future" together with a date in the future to schedule.',
635 'enum' => [ 'publish', 'draft', 'pending', 'private', 'future' ],
636 'default' => 'draft',
637 ],
638 'content' => [
639 'type' => 'string',
640 'description' => 'Post body as an inline string. Plain text, raw HTML, or Gutenberg block markup. See content_format to control processing. Mutually exclusive with content_url — provide one, not both.',
641 ],
642 'content_url' => [
643 'type' => 'string',
644 'description' => 'Alternative to content: a URL to pull the post body from. The response body is fetched verbatim (HTML, PHP source, plain text, or block markup — no sanitisation) and then processed per content_format; use content_format:"raw" to store it byte-for-byte. Must be a publicly reachable http/https URL — requests to private/loopback addresses are rejected. Mutually exclusive with content.',
645 ],
646 'content_format' => [
647 'type' => 'string',
648 'description' => 'How to process the content (or fetched content_url) body. "auto" (default): detect block delimiters and pass through if present, otherwise wrap paragraphs as wp:paragraph blocks so the result stays editable in the block editor. "raw": store content exactly as provided, no processing (use this for HTML/PHP/other file content that must not be altered). "blocks": caller asserts content is already valid block markup; pass through with no detection.',
649 'enum' => [ 'auto', 'raw', 'blocks' ],
650 'default' => 'auto',
651 ],
652 'excerpt' => [
653 'type' => 'string',
654 'description' => 'Hand-written excerpt. Omit to let WordPress generate one from the content.',
655 ],
656 'date' => [
657 'type' => 'string',
658 'description' => 'Publish date in site-local time. Accepts ISO 8601 (2026-05-22T14:30:00) or any strtotime()-parseable string. For status="future", must be in the future.',
659 ],
660 'author' => [
661 'type' => 'integer',
662 'description' => 'User ID of the post author. Defaults to the current user. Setting this to another user requires edit_others_posts capability for the post type.',
663 'minimum' => 1,
664 ],
665 'parent' => [
666 'type' => 'integer',
667 'description' => 'Parent post ID for hierarchical post types (pages, custom hierarchical CPTs). 0 means no parent.',
668 'minimum' => 0,
669 ],
670 'featured_media' => [
671 'type' => 'integer',
672 'description' => 'Attachment ID to use as the featured image. The attachment must already exist in the media library and be an image (not a PDF, video, or audio file).',
673 'minimum' => 1,
674 ],
675 'comment_status' => [
676 'type' => 'string',
677 'description' => 'Whether comments are allowed. Defaults to the site-wide setting.',
678 'enum' => [ 'open', 'closed' ],
679 ],
680 'ping_status' => [
681 'type' => 'string',
682 'description' => 'Whether pingbacks and trackbacks are allowed. Defaults to the site-wide setting.',
683 'enum' => [ 'open', 'closed' ],
684 ],
685 'meta' => [
686 'type' => 'object',
687 'description' => 'Optional map of post-meta keys to values, applied atomically at creation so you do not need follow-up write calls. Values are stored slash-safe, so JSON meta such as Elementor\'s _elementor_data round-trips intact. Underscore-prefixed keys (e.g. _elementor_data, _elementor_edit_mode, _elementor_template_type, _wp_page_template) are allowed. For framework fields (ACF / Toolset / Meta Box) that need their own write hooks, use update-post-field after creation instead.',
688 'additionalProperties' => true,
689 ],
690 ],
691 'required' => [ 'title' ],
692 'additionalProperties' => false,
693 ],
694 'output_schema' => [
695 'type' => 'object',
696 'properties' => [
697 'success' => [ 'type' => 'boolean' ],
698 'id' => [ 'type' => 'integer' ],
699 'title' => [ 'type' => 'string' ],
700 'slug' => [ 'type' => 'string' ],
701 'status' => [ 'type' => 'string' ],
702 'post_type' => [ 'type' => 'string' ],
703 'url' => [ 'type' => 'string' ],
704 'excerpt' => [ 'type' => 'string' ],
705 'date' => [ 'type' => 'string' ],
706 'author' => [ 'type' => 'integer' ],
707 'parent' => [ 'type' => 'integer' ],
708 'featured_media' => [ 'type' => 'integer' ],
709 'comment_status' => [ 'type' => 'string' ],
710 'ping_status' => [ 'type' => 'string' ],
711 'message' => [ 'type' => 'string' ],
712 ],
713 'required' => [ 'success', 'message' ],
714 ],
715 'execute_callback' => function( $input = [] ) {
716 // Default here too: the framework validator runs before this
717 // callback, so relying on the schema default alone would still
718 // reject a call the plugin is happy to handle.
719 $requested_post_type = isset( $input['post_type'] ) ? sanitize_key( (string) $input['post_type'] ) : '';
720 $post_type = '' !== $requested_post_type ? $requested_post_type : 'post';
721 $title = isset( $input['title'] ) ? sanitize_text_field( $input['title'] ) : '';
722
723 if ( empty( $post_type ) || empty( $title ) ) {
724 return [
725 'success' => false,
726 'message' => 'post_type and title are required.',
727 ];
728 }
729
730 if ( ! post_type_exists( $post_type ) ) {
731 return [
732 'success' => false,
733 'message' => sprintf( 'Post type "%s" does not exist on this site.', $post_type ),
734 ];
735 }
736
737 if ( in_array( $post_type, $this->avcf_write_protected_post_types(), true ) ) {
738 return [
739 'success' => false,
740 'message' => sprintf(
741 'Post type "%s" cannot be created through this ability (internal/structural type).%s',
742 $post_type,
743 $this->avcf_write_protected_hint( $post_type )
744 ),
745 ];
746 }
747
748 $pt_obj = get_post_type_object( $post_type );
749 if ( $pt_obj && ! current_user_can( $pt_obj->cap->edit_posts ) ) {
750 return [
751 'success' => false,
752 'message' => sprintf( 'You do not have permission to create %s.', $post_type ),
753 ];
754 }
755
756 // Status — default to draft, validate enum.
757 $allowed_statuses = [ 'publish', 'draft', 'pending', 'private', 'future' ];
758 $status = isset( $input['status'] ) ? sanitize_key( $input['status'] ) : 'draft';
759 if ( ! in_array( $status, $allowed_statuses, true ) ) {
760 $status = 'draft';
761 }
762
763 // Publishing requires the publish capability for this post type.
764 if ( in_array( $status, [ 'publish', 'future', 'private' ], true ) ) {
765 if ( $pt_obj && ! current_user_can( $pt_obj->cap->publish_posts ) ) {
766 return [
767 'success' => false,
768 'message' => sprintf( 'You do not have permission to publish %s.', $post_type ),
769 ];
770 }
771 }
772
773 $postarr = [
774 'post_type' => $post_type,
775 'post_title' => $title,
776 'post_status' => $status,
777 ];
778
779 // Slug — optional; let wp_insert_post auto-generate from title if omitted.
780 if ( isset( $input['slug'] ) && $input['slug'] !== '' ) {
781 $postarr['post_name'] = sanitize_title( $input['slug'] );
782 }
783
784 // Content body — inline (content) or pulled from a URL (content_url).
785 $has_content = array_key_exists( 'content', $input );
786 $has_content_url = isset( $input['content_url'] ) && trim( (string) $input['content_url'] ) !== '';
787 if ( $has_content && $has_content_url ) {
788 return [
789 'success' => false,
790 'message' => 'Provide either content or content_url, not both.',
791 ];
792 }
793 if ( $has_content || $has_content_url ) {
794 $format = isset( $input['content_format'] ) ? (string) $input['content_format'] : 'auto';
795 if ( $has_content_url ) {
796 list( $fetched_content, $fetch_err ) = $this->avcf_fetch_content_from_url( (string) $input['content_url'] );
797 if ( $fetch_err !== null ) {
798 return [ 'success' => false, 'message' => $fetch_err ];
799 }
800 $raw_content = $fetched_content;
801 } else {
802 $raw_content = (string) $input['content'];
803 }
804 $postarr['post_content'] = $this->avcf_prepare_content_body( $raw_content, $format );
805 }
806
807 // Excerpt.
808 if ( isset( $input['excerpt'] ) ) {
809 $postarr['post_excerpt'] = sanitize_textarea_field( (string) $input['excerpt'] );
810 }
811
812 // Date — parse, normalise, validate "future" constraint.
813 if ( isset( $input['date'] ) && $input['date'] !== '' ) {
814 list( $local_date, $gmt_date, $date_err ) = $this->avcf_normalize_post_date( (string) $input['date'] );
815 if ( $date_err !== null ) {
816 return [ 'success' => false, 'message' => $date_err ];
817 }
818 if ( $status === 'future' && strtotime( $gmt_date ) <= time() ) {
819 return [
820 'success' => false,
821 'message' => 'status="future" requires a date in the future.',
822 ];
823 }
824 $postarr['post_date'] = $local_date;
825 $postarr['post_date_gmt'] = $gmt_date;
826 }
827
828 // Author — defaults to current user; requires edit_others_posts to set someone else.
829 if ( isset( $input['author'] ) ) {
830 $author_id = (int) $input['author'];
831 if ( $author_id <= 0 ) {
832 return [ 'success' => false, 'message' => 'author must be a positive user ID.' ];
833 }
834 if ( ! get_userdata( $author_id ) ) {
835 return [ 'success' => false, 'message' => sprintf( 'User %d does not exist.', $author_id ) ];
836 }
837 if ( $author_id !== get_current_user_id() ) {
838 if ( $pt_obj && ! current_user_can( $pt_obj->cap->edit_others_posts ) ) {
839 return [
840 'success' => false,
841 'message' => sprintf( 'You do not have permission to assign %s to another author.', $post_type ),
842 ];
843 }
844 }
845 $postarr['post_author'] = $author_id;
846 }
847
848 // Parent — validate hierarchical and that the parent exists.
849 if ( isset( $input['parent'] ) ) {
850 $parent_id = (int) $input['parent'];
851 if ( $parent_id < 0 ) {
852 return [ 'success' => false, 'message' => 'parent must be 0 or a positive post ID.' ];
853 }
854 if ( $parent_id > 0 ) {
855 if ( ! is_post_type_hierarchical( $post_type ) ) {
856 return [
857 'success' => false,
858 'message' => sprintf( 'Post type "%s" is not hierarchical; parent must be 0.', $post_type ),
859 ];
860 }
861 $parent_post = get_post( $parent_id );
862 if ( ! $parent_post || $parent_post->post_type !== $post_type ) {
863 return [
864 'success' => false,
865 'message' => sprintf( 'Parent %d does not exist or is not a %s.', $parent_id, $post_type ),
866 ];
867 }
868 }
869 $postarr['post_parent'] = $parent_id;
870 }
871
872 // Featured media — validate existence + is-image. Stored via _thumbnail_id meta after insert.
873 $featured_media_id = null;
874 if ( isset( $input['featured_media'] ) ) {
875 $featured_media_id = (int) $input['featured_media'];
876 $attach_err = $this->avcf_validate_attachment_id( $featured_media_id );
877 if ( $attach_err !== null ) {
878 return [ 'success' => false, 'message' => $attach_err ];
879 }
880 }
881
882 // Comment / ping status.
883 if ( isset( $input['comment_status'] ) ) {
884 $cs = sanitize_key( $input['comment_status'] );
885 if ( ! in_array( $cs, [ 'open', 'closed' ], true ) ) {
886 return [ 'success' => false, 'message' => 'comment_status must be "open" or "closed".' ];
887 }
888 $postarr['comment_status'] = $cs;
889 }
890 if ( isset( $input['ping_status'] ) ) {
891 $ps = sanitize_key( $input['ping_status'] );
892 if ( ! in_array( $ps, [ 'open', 'closed' ], true ) ) {
893 return [ 'success' => false, 'message' => 'ping_status must be "open" or "closed".' ];
894 }
895 $postarr['ping_status'] = $ps;
896 }
897
898 $post_id = wp_insert_post( $postarr, true );
899
900 if ( is_wp_error( $post_id ) ) {
901 return [
902 'success' => false,
903 'message' => 'Creation failed: ' . $post_id->get_error_message(),
904 ];
905 }
906
907 // Set featured image after insert (wp_insert_post does not accept _thumbnail_id directly).
908 if ( $featured_media_id !== null ) {
909 set_post_thumbnail( $post_id, $featured_media_id );
910 }
911
912 // Apply an optional meta map atomically at creation. Written via
913 // update_post_meta with wp_slash so values (e.g. the _elementor_data
914 // JSON blob) round-trip intact — update_metadata unslashes internally.
915 $meta_written = [];
916 if ( isset( $input['meta'] ) && ( is_array( $input['meta'] ) || is_object( $input['meta'] ) ) ) {
917 foreach ( (array) $input['meta'] as $mk => $mv ) {
918 $mk = (string) $mk;
919 if ( $mk === '' ) { continue; }
920 update_post_meta( $post_id, $mk, wp_slash( $mv ) );
921 $meta_written[] = $mk;
922 }
923 }
924
925 $post = get_post( $post_id );
926
927 // Body write-receipt (see update-content): confirm the stored body
928 // without echoing it. content_verified compares intended vs stored.
929 $stored_body = (string) $post->post_content;
930 $content_receipt = [
931 'content_bytes' => strlen( $stored_body ),
932 'content_sha1' => sha1( $stored_body ),
933 ];
934 if ( array_key_exists( 'post_content', $postarr ) ) {
935 $content_receipt['content_verified'] = ( sha1( (string) $postarr['post_content'] ) === sha1( $stored_body ) );
936 }
937
938 return array_merge( [
939 'success' => true,
940 'id' => $post_id,
941 'title' => $post->post_title,
942 'slug' => $post->post_name,
943 'status' => $post->post_status,
944 'post_type' => $post->post_type,
945 'url' => get_permalink( $post_id ),
946 'excerpt' => $post->post_excerpt,
947 'date' => $post->post_date,
948 'author' => (int) $post->post_author,
949 'parent' => (int) $post->post_parent,
950 'featured_media' => (int) get_post_thumbnail_id( $post_id ),
951 'comment_status' => $post->comment_status,
952 'ping_status' => $post->ping_status,
953 'meta_written' => $meta_written,
954 ], $content_receipt, [
955 'message' => empty( $meta_written ) ? 'Content created.' : sprintf( 'Content created; %d meta key(s) set: %s.', count( $meta_written ), implode( ', ', $meta_written ) ),
956 ] );
957 },
958 'permission_callback' => function() {
959 return current_user_can( 'edit_posts' );
960 },
961 'meta' => [
962 'mcp' => [ 'public' => true, 'type' => 'tool' ],
963 'annotations' => [
964 'readonly' => false,
965 'destructive' => false,
966 'idempotent' => false,
967 ],
968 ],
969 ] );
970
971 // ---- update-content ----
972 wp_register_ability( 'atarim/update-content', [
973 'label' => 'Update Content',
974 'description' => 'Updates an existing post, page, or custom post type item. Only the id is required; pass any subset of the other fields to update those. Omitted fields are left unchanged. The content body can be supplied inline (content — plain text, raw HTML, or Gutenberg block markup) or pulled from a URL (content_url); see content_format.',
975 'category' => 'atarim',
976 'input_schema' => [
977 'type' => 'object',
978 'properties' => [
979 'id' => [
980 'type' => 'integer',
981 'description' => 'Post ID. Required.',
982 'minimum' => 1,
983 ],
984 'title' => [
985 'type' => 'string',
986 'description' => 'New title. Omit to leave unchanged.',
987 'minLength' => 1,
988 ],
989 'slug' => [
990 'type' => 'string',
991 'description' => 'New URL slug. WordPress auto-suffixes on conflict. Omit to leave unchanged.',
992 'minLength' => 1,
993 ],
994 'status' => [
995 'type' => 'string',
996 'description' => 'New publish status. Use "future" with a future-dated "date" to schedule. Omit to leave unchanged.',
997 'enum' => [ 'publish', 'draft', 'pending', 'private', 'future' ],
998 ],
999 'content' => [
1000 'type' => 'string',
1001 'description' => 'New post body as an inline string. Plain text, raw HTML, or Gutenberg block markup. See content_format. Pass an empty string to clear the body. Omit to leave unchanged. Mutually exclusive with content_url.',
1002 ],
1003 'content_url' => [
1004 'type' => 'string',
1005 'description' => 'Alternative to content: a URL to pull the new post body from. The response body is fetched verbatim (HTML, PHP source, plain text, or block markup — no sanitisation) and then processed per content_format; use content_format:"raw" to store it byte-for-byte. Must be a publicly reachable http/https URL — requests to private/loopback addresses are rejected. Mutually exclusive with content.',
1006 ],
1007 'content_format' => [
1008 'type' => 'string',
1009 'description' => 'How to process the content (or fetched content_url) body. "auto" (default), "raw", or "blocks". See create-content for details.',
1010 'enum' => [ 'auto', 'raw', 'blocks' ],
1011 'default' => 'auto',
1012 ],
1013 'excerpt' => [
1014 'type' => 'string',
1015 'description' => 'New excerpt. Pass an empty string to clear. Omit to leave unchanged.',
1016 ],
1017 'date' => [
1018 'type' => 'string',
1019 'description' => 'New publish date in site-local time. ISO 8601 or strtotime()-parseable. Omit to leave unchanged.',
1020 ],
1021 'author' => [
1022 'type' => 'integer',
1023 'description' => 'New author user ID. Requires edit_others_posts capability if different from current author. Omit to leave unchanged.',
1024 'minimum' => 1,
1025 ],
1026 'parent' => [
1027 'type' => 'integer',
1028 'description' => 'New parent post ID for hierarchical post types. 0 removes the parent. Omit to leave unchanged.',
1029 'minimum' => 0,
1030 ],
1031 'featured_media' => [
1032 'type' => 'integer',
1033 'description' => 'New featured image attachment ID. Must exist and be an image. Pass 0 to remove the featured image. Omit to leave unchanged.',
1034 'minimum' => 0,
1035 ],
1036 'comment_status' => [
1037 'type' => 'string',
1038 'description' => 'New comment status. Omit to leave unchanged.',
1039 'enum' => [ 'open', 'closed' ],
1040 ],
1041 'ping_status' => [
1042 'type' => 'string',
1043 'description' => 'New pingback/trackback status. Omit to leave unchanged.',
1044 'enum' => [ 'open', 'closed' ],
1045 ],
1046 ],
1047 'required' => [ 'id' ],
1048 'additionalProperties' => false,
1049 ],
1050 'output_schema' => [
1051 'type' => 'object',
1052 'properties' => [
1053 'success' => [ 'type' => 'boolean' ],
1054 'id' => [ 'type' => 'integer' ],
1055 'title' => [ 'type' => 'string' ],
1056 'slug' => [ 'type' => 'string' ],
1057 'status' => [ 'type' => 'string' ],
1058 'post_type' => [ 'type' => 'string' ],
1059 'url' => [ 'type' => 'string' ],
1060 'excerpt' => [ 'type' => 'string' ],
1061 'date' => [ 'type' => 'string' ],
1062 'author' => [ 'type' => 'integer' ],
1063 'parent' => [ 'type' => 'integer' ],
1064 'featured_media' => [ 'type' => 'integer' ],
1065 'comment_status' => [ 'type' => 'string' ],
1066 'ping_status' => [ 'type' => 'string' ],
1067 'updated' => [ 'type' => 'array', 'items' => [ 'type' => 'string' ] ],
1068 'message' => [ 'type' => 'string' ],
1069 ],
1070 'required' => [ 'success', 'message' ],
1071 ],
1072 'execute_callback' => function( $input = [] ) {
1073 $id = isset( $input['id'] ) ? (int) $input['id'] : 0;
1074 if ( $id <= 0 ) {
1075 return [
1076 'success' => false,
1077 'message' => 'id is required and must be a positive integer.',
1078 ];
1079 }
1080
1081 $post = get_post( $id );
1082 if ( ! $post ) {
1083 return [
1084 'success' => false,
1085 'message' => sprintf( 'Post %d not found.', $id ),
1086 ];
1087 }
1088
1089 if ( in_array( $post->post_type, $this->avcf_write_protected_post_types(), true ) ) {
1090 return [
1091 'success' => false,
1092 'message' => sprintf(
1093 'Post type "%s" cannot be edited through this ability (internal/structural type).%s',
1094 $post->post_type,
1095 $this->avcf_write_protected_hint( $post->post_type )
1096 ),
1097 ];
1098 }
1099
1100 $pt_obj = get_post_type_object( $post->post_type );
1101 if ( $pt_obj && ! current_user_can( $pt_obj->cap->edit_post, $id ) ) {
1102 return [
1103 'success' => false,
1104 'message' => sprintf( 'You do not have permission to edit this %s.', $post->post_type ),
1105 ];
1106 }
1107
1108 // Build the update payload from only the fields the caller actually sent.
1109 // wp_update_post leaves omitted fields untouched, but we track what we
1110 // changed so we can return a useful `updated` array.
1111 $update = [ 'ID' => $id ];
1112 $updated = [];
1113
1114 if ( array_key_exists( 'title', $input ) ) {
1115 $update['post_title'] = sanitize_text_field( (string) $input['title'] );
1116 $updated[] = 'title';
1117 }
1118
1119 if ( array_key_exists( 'slug', $input ) ) {
1120 $update['post_name'] = sanitize_title( (string) $input['slug'] );
1121 $updated[] = 'slug';
1122 }
1123
1124 // Status — needs publish_posts cap if moving into publish/future/private.
1125 if ( array_key_exists( 'status', $input ) ) {
1126 $status = sanitize_key( (string) $input['status'] );
1127 $allowed = [ 'publish', 'draft', 'pending', 'private', 'future' ];
1128 if ( ! in_array( $status, $allowed, true ) ) {
1129 return [
1130 'success' => false,
1131 'message' => sprintf( 'Invalid status "%s". Allowed: publish, draft, pending, private, future.', $status ),
1132 ];
1133 }
1134 if ( in_array( $status, [ 'publish', 'future', 'private' ], true ) ) {
1135 if ( $pt_obj && ! current_user_can( $pt_obj->cap->publish_posts ) ) {
1136 return [
1137 'success' => false,
1138 'message' => sprintf( 'You do not have permission to publish %s.', $post->post_type ),
1139 ];
1140 }
1141 }
1142 $update['post_status'] = $status;
1143 $updated[] = 'status';
1144 }
1145
1146 // Content body — inline (content) or pulled from a URL (content_url).
1147 $has_content = array_key_exists( 'content', $input );
1148 $has_content_url = isset( $input['content_url'] ) && trim( (string) $input['content_url'] ) !== '';
1149 if ( $has_content && $has_content_url ) {
1150 return [
1151 'success' => false,
1152 'message' => 'Provide either content or content_url, not both.',
1153 ];
1154 }
1155 if ( $has_content || $has_content_url ) {
1156 $format = isset( $input['content_format'] ) ? (string) $input['content_format'] : 'auto';
1157 if ( $has_content_url ) {
1158 list( $fetched_content, $fetch_err ) = $this->avcf_fetch_content_from_url( (string) $input['content_url'] );
1159 if ( $fetch_err !== null ) {
1160 return [ 'success' => false, 'message' => $fetch_err ];
1161 }
1162 $raw_content = $fetched_content;
1163 } else {
1164 $raw_content = (string) $input['content'];
1165 }
1166 $update['post_content'] = $this->avcf_prepare_content_body( $raw_content, $format );
1167 $updated[] = 'content';
1168 }
1169
1170 if ( array_key_exists( 'excerpt', $input ) ) {
1171 $update['post_excerpt'] = sanitize_textarea_field( (string) $input['excerpt'] );
1172 $updated[] = 'excerpt';
1173 }
1174
1175 if ( array_key_exists( 'date', $input ) && $input['date'] !== '' ) {
1176 list( $local_date, $gmt_date, $date_err ) = $this->avcf_normalize_post_date( (string) $input['date'] );
1177 if ( $date_err !== null ) {
1178 return [ 'success' => false, 'message' => $date_err ];
1179 }
1180 // If status is being set to "future" in this same call, validate date is in the future.
1181 $effective_status = isset( $update['post_status'] ) ? $update['post_status'] : $post->post_status;
1182 if ( $effective_status === 'future' && strtotime( $gmt_date ) <= time() ) {
1183 return [
1184 'success' => false,
1185 'message' => 'status="future" requires a date in the future.',
1186 ];
1187 }
1188 $update['post_date'] = $local_date;
1189 $update['post_date_gmt'] = $gmt_date;
1190 $updated[] = 'date';
1191 }
1192
1193 if ( array_key_exists( 'author', $input ) ) {
1194 $author_id = (int) $input['author'];
1195 if ( $author_id <= 0 ) {
1196 return [ 'success' => false, 'message' => 'author must be a positive user ID.' ];
1197 }
1198 if ( ! get_userdata( $author_id ) ) {
1199 return [ 'success' => false, 'message' => sprintf( 'User %d does not exist.', $author_id ) ];
1200 }
1201 if ( $author_id !== get_current_user_id() ) {
1202 if ( $pt_obj && ! current_user_can( $pt_obj->cap->edit_others_posts ) ) {
1203 return [
1204 'success' => false,
1205 'message' => sprintf( 'You do not have permission to assign %s to another author.', $post->post_type ),
1206 ];
1207 }
1208 }
1209 $update['post_author'] = $author_id;
1210 $updated[] = 'author';
1211 }
1212
1213 if ( array_key_exists( 'parent', $input ) ) {
1214 $parent_id = (int) $input['parent'];
1215 if ( $parent_id < 0 ) {
1216 return [ 'success' => false, 'message' => 'parent must be 0 or a positive post ID.' ];
1217 }
1218 if ( $parent_id > 0 ) {
1219 if ( ! is_post_type_hierarchical( $post->post_type ) ) {
1220 return [
1221 'success' => false,
1222 'message' => sprintf( 'Post type "%s" is not hierarchical; parent must be 0.', $post->post_type ),
1223 ];
1224 }
1225 if ( $parent_id === $id ) {
1226 return [ 'success' => false, 'message' => 'A post cannot be its own parent.' ];
1227 }
1228 $parent_post = get_post( $parent_id );
1229 if ( ! $parent_post || $parent_post->post_type !== $post->post_type ) {
1230 return [
1231 'success' => false,
1232 'message' => sprintf( 'Parent %d does not exist or is not a %s.', $parent_id, $post->post_type ),
1233 ];
1234 }
1235 }
1236 $update['post_parent'] = $parent_id;
1237 $updated[] = 'parent';
1238 }
1239
1240 // Featured media — handled separately (post-insert via set_post_thumbnail).
1241 // 0 = remove the featured image, positive = validate and set.
1242 $featured_media_change = null; // null = unchanged, 0 = remove, >0 = set
1243 if ( array_key_exists( 'featured_media', $input ) ) {
1244 $featured_media_id = (int) $input['featured_media'];
1245 if ( $featured_media_id < 0 ) {
1246 return [ 'success' => false, 'message' => 'featured_media must be 0 or a positive attachment ID.' ];
1247 }
1248 if ( $featured_media_id > 0 ) {
1249 $attach_err = $this->avcf_validate_attachment_id( $featured_media_id );
1250 if ( $attach_err !== null ) {
1251 return [ 'success' => false, 'message' => $attach_err ];
1252 }
1253 }
1254 $featured_media_change = $featured_media_id;
1255 $updated[] = 'featured_media';
1256 }
1257
1258 if ( array_key_exists( 'comment_status', $input ) ) {
1259 $cs = sanitize_key( (string) $input['comment_status'] );
1260 if ( ! in_array( $cs, [ 'open', 'closed' ], true ) ) {
1261 return [ 'success' => false, 'message' => 'comment_status must be "open" or "closed".' ];
1262 }
1263 $update['comment_status'] = $cs;
1264 $updated[] = 'comment_status';
1265 }
1266
1267 if ( array_key_exists( 'ping_status', $input ) ) {
1268 $ps = sanitize_key( (string) $input['ping_status'] );
1269 if ( ! in_array( $ps, [ 'open', 'closed' ], true ) ) {
1270 return [ 'success' => false, 'message' => 'ping_status must be "open" or "closed".' ];
1271 }
1272 $update['ping_status'] = $ps;
1273 $updated[] = 'ping_status';
1274 }
1275
1276 // Nothing actually changed (only ID was passed, and no featured_media change).
1277 if ( count( $update ) === 1 && $featured_media_change === null ) {
1278 return [
1279 'success' => false,
1280 'message' => 'No fields provided to update. Pass at least one field to change.',
1281 ];
1282 }
1283
1284 // Only call wp_update_post if there's something in the post table to update.
1285 if ( count( $update ) > 1 ) {
1286 $result = wp_update_post( $update, true );
1287 if ( is_wp_error( $result ) ) {
1288 return [
1289 'success' => false,
1290 'message' => 'Update failed: ' . $result->get_error_message(),
1291 ];
1292 }
1293 }
1294
1295 // Apply featured image change (separately because wp_update_post does not handle _thumbnail_id).
1296 if ( $featured_media_change !== null ) {
1297 if ( $featured_media_change === 0 ) {
1298 delete_post_thumbnail( $id );
1299 } else {
1300 set_post_thumbnail( $id, $featured_media_change );
1301 }
1302 }
1303
1304 $fresh = get_post( $id );
1305
1306 // Body write-receipt: confirm the stored content without a separate
1307 // (potentially very large) read. content_verified compares what we
1308 // intended to store against what is actually stored, byte-for-byte.
1309 $stored_body = (string) $fresh->post_content;
1310 $content_receipt = [
1311 'content_bytes' => strlen( $stored_body ),
1312 'content_sha1' => sha1( $stored_body ),
1313 'content_changed' => ( sha1( (string) $post->post_content ) !== sha1( $stored_body ) ),
1314 ];
1315 if ( array_key_exists( 'post_content', $update ) ) {
1316 $content_receipt['content_verified'] = ( sha1( (string) $update['post_content'] ) === sha1( $stored_body ) );
1317 }
1318
1319 return array_merge( [
1320 'success' => true,
1321 'id' => $id,
1322 'title' => $fresh->post_title,
1323 'slug' => $fresh->post_name,
1324 'status' => $fresh->post_status,
1325 'post_type' => $fresh->post_type,
1326 'url' => get_permalink( $id ),
1327 'excerpt' => $fresh->post_excerpt,
1328 'date' => $fresh->post_date,
1329 'author' => (int) $fresh->post_author,
1330 'parent' => (int) $fresh->post_parent,
1331 'featured_media' => (int) get_post_thumbnail_id( $id ),
1332 'comment_status' => $fresh->comment_status,
1333 'ping_status' => $fresh->ping_status,
1334 'updated' => $updated,
1335 ], $content_receipt, [
1336 'message' => sprintf( 'Updated: %s.', implode( ', ', $updated ) ),
1337 ] );
1338 },
1339 'permission_callback' => function() {
1340 return current_user_can( 'edit_posts' );
1341 },
1342 'meta' => [
1343 'mcp' => [ 'public' => true, 'type' => 'tool' ],
1344 'annotations' => [
1345 'readonly' => false,
1346 'destructive' => false,
1347 'idempotent' => true,
1348 ],
1349 ],
1350 ] );
1351
1352 // ---- bulk-update-content ----
1353 wp_register_ability( 'atarim/bulk-update-content', [
1354 'label' => 'Bulk Update Content',
1355 'description' => 'Updates one field across many posts in a single call. Designed for sweeping changes — moving many drafts to published, reassigning posts to a new author after a user leaves, closing comments across a batch. Mixed-field-per-id updates are not supported by design; use update-content in a loop for those. Returns per-id success/failure tracking so partial failures (e.g. capability checks) don\'t mask the rest.',
1356 'category' => 'atarim',
1357 'input_schema' => [
1358 'type' => 'object',
1359 'properties' => [
1360 'ids' => [
1361 'type' => 'array',
1362 'description' => 'Post IDs to update.',
1363 'items' => [ 'type' => 'integer', 'minimum' => 1 ],
1364 'minItems' => 1,
1365 'maxItems' => 500,
1366 ],
1367 'field' => [
1368 'type' => 'string',
1369 'description' => 'Which field to update on every targeted post. Bulk operations are limited to fields that make sense applied uniformly — status, author, parent, comment_status, ping_status. Use update-content for per-post fields like title or content.',
1370 'enum' => [ 'status', 'author', 'parent', 'comment_status', 'ping_status' ],
1371 ],
1372 'value' => [
1373 'description' => 'The new value for the chosen field. Type depends on the field: string for status / comment_status / ping_status, integer for author / parent.',
1374 ],
1375 ],
1376 'required' => [ 'ids', 'field', 'value' ],
1377 'additionalProperties' => false,
1378 ],
1379 'output_schema' => [
1380 'type' => 'object',
1381 'properties' => [
1382 'success' => [ 'type' => 'boolean' ],
1383 'attempted' => [ 'type' => 'integer' ],
1384 'updated' => [ 'type' => 'integer' ],
1385 'failed' => [ 'type' => 'integer' ],
1386 'results' => [
1387 'type' => 'array',
1388 'items' => [
1389 'type' => 'object',
1390 'properties' => [
1391 'id' => [ 'type' => 'integer' ],
1392 'success' => [ 'type' => 'boolean' ],
1393 'message' => [ 'type' => 'string' ],
1394 ],
1395 ],
1396 ],
1397 'message' => [ 'type' => 'string' ],
1398 ],
1399 'required' => [ 'success', 'attempted', 'updated', 'failed', 'results', 'message' ],
1400 ],
1401 'execute_callback' => function( $input = [] ) {
1402 $ids = isset( $input['ids'] ) && is_array( $input['ids'] ) ? array_values( array_unique( array_map( 'intval', $input['ids'] ) ) ) : [];
1403 $field = isset( $input['field'] ) ? sanitize_key( $input['field'] ) : '';
1404 $value = $input['value'] ?? null;
1405
1406 if ( empty( $ids ) ) {
1407 return [
1408 'success' => false,
1409 'attempted' => 0,
1410 'updated' => 0,
1411 'failed' => 0,
1412 'results' => [],
1413 'message' => 'ids is required and must be a non-empty array of positive integers.',
1414 ];
1415 }
1416
1417 $allowed_fields = [ 'status', 'author', 'parent', 'comment_status', 'ping_status' ];
1418 if ( ! in_array( $field, $allowed_fields, true ) ) {
1419 return [
1420 'success' => false,
1421 'attempted' => count( $ids ),
1422 'updated' => 0,
1423 'failed' => 0,
1424 'results' => [],
1425 'message' => sprintf( 'field must be one of: %s.', implode( ', ', $allowed_fields ) ),
1426 ];
1427 }
1428
1429 // Validate the value once up-front. Same rules apply to every post.
1430 $normalized_value = null;
1431 $value_error = null;
1432
1433 switch ( $field ) {
1434 case 'status':
1435 $s = is_string( $value ) ? sanitize_key( $value ) : '';
1436 if ( ! in_array( $s, [ 'publish', 'draft', 'pending', 'private', 'future' ], true ) ) {
1437 $value_error = 'value must be one of: publish, draft, pending, private, future.';
1438 }
1439 $normalized_value = $s;
1440 break;
1441 case 'author':
1442 $a = (int) $value;
1443 if ( $a <= 0 ) {
1444 $value_error = 'value must be a positive user ID.';
1445 } elseif ( ! get_userdata( $a ) ) {
1446 $value_error = sprintf( 'User %d does not exist.', $a );
1447 }
1448 $normalized_value = $a;
1449 break;
1450 case 'parent':
1451 $p = (int) $value;
1452 if ( $p < 0 ) {
1453 $value_error = 'value must be 0 or a positive post ID.';
1454 }
1455 $normalized_value = $p;
1456 break;
1457 case 'comment_status':
1458 case 'ping_status':
1459 $s = is_string( $value ) ? sanitize_key( $value ) : '';
1460 if ( ! in_array( $s, [ 'open', 'closed' ], true ) ) {
1461 $value_error = sprintf( 'value must be "open" or "closed" for %s.', $field );
1462 }
1463 $normalized_value = $s;
1464 break;
1465 }
1466
1467 if ( $value_error !== null ) {
1468 return [
1469 'success' => false,
1470 'attempted' => count( $ids ),
1471 'updated' => 0,
1472 'failed' => 0,
1473 'results' => [],
1474 'message' => $value_error,
1475 ];
1476 }
1477
1478 // Per-id processing.
1479 $results = [];
1480 $updated = 0;
1481 $failed = 0;
1482
1483 foreach ( $ids as $id ) {
1484 if ( $id <= 0 ) {
1485 $results[] = [ 'id' => $id, 'success' => false, 'message' => 'Invalid id.' ];
1486 $failed++;
1487 continue;
1488 }
1489
1490 $post = get_post( $id );
1491 if ( ! $post ) {
1492 $results[] = [ 'id' => $id, 'success' => false, 'message' => 'Post not found.' ];
1493 $failed++;
1494 continue;
1495 }
1496
1497 $pt_obj = get_post_type_object( $post->post_type );
1498 if ( $pt_obj && ! current_user_can( $pt_obj->cap->edit_post, $id ) ) {
1499 $results[] = [ 'id' => $id, 'success' => false, 'message' => 'Permission denied.' ];
1500 $failed++;
1501 continue;
1502 }
1503
1504 // Field-specific additional checks.
1505 if ( $field === 'status' && in_array( $normalized_value, [ 'publish', 'private', 'future' ], true ) ) {
1506 if ( $pt_obj && ! current_user_can( $pt_obj->cap->publish_posts ) ) {
1507 $results[] = [ 'id' => $id, 'success' => false, 'message' => 'Permission denied (publish capability required).' ];
1508 $failed++;
1509 continue;
1510 }
1511 }
1512 if ( $field === 'author' && $normalized_value !== (int) $post->post_author ) {
1513 if ( $pt_obj && ! current_user_can( $pt_obj->cap->edit_others_posts ) ) {
1514 $results[] = [ 'id' => $id, 'success' => false, 'message' => 'Permission denied (edit_others_posts capability required).' ];
1515 $failed++;
1516 continue;
1517 }
1518 }
1519 if ( $field === 'parent' && $normalized_value > 0 ) {
1520 if ( ! is_post_type_hierarchical( $post->post_type ) ) {
1521 $results[] = [ 'id' => $id, 'success' => false, 'message' => sprintf( 'Post type "%s" is not hierarchical.', $post->post_type ) ];
1522 $failed++;
1523 continue;
1524 }
1525 if ( $normalized_value === $id ) {
1526 $results[] = [ 'id' => $id, 'success' => false, 'message' => 'A post cannot be its own parent.' ];
1527 $failed++;
1528 continue;
1529 }
1530 }
1531
1532 $update = [ 'ID' => $id ];
1533 switch ( $field ) {
1534 case 'status': $update['post_status'] = $normalized_value; break;
1535 case 'author': $update['post_author'] = $normalized_value; break;
1536 case 'parent': $update['post_parent'] = $normalized_value; break;
1537 case 'comment_status': $update['comment_status'] = $normalized_value; break;
1538 case 'ping_status': $update['ping_status'] = $normalized_value; break;
1539 }
1540
1541 $result = wp_update_post( $update, true );
1542 if ( is_wp_error( $result ) ) {
1543 $results[] = [ 'id' => $id, 'success' => false, 'message' => $result->get_error_message() ];
1544 $failed++;
1545 continue;
1546 }
1547
1548 $results[] = [ 'id' => $id, 'success' => true, 'message' => 'OK.' ];
1549 $updated++;
1550 }
1551
1552 $attempted = count( $ids );
1553
1554 return [
1555 'success' => ( $failed === 0 ),
1556 'attempted' => $attempted,
1557 'updated' => $updated,
1558 'failed' => $failed,
1559 'results' => $results,
1560 'message' => sprintf( '%d of %d updated, %d failed.', $updated, $attempted, $failed ),
1561 ];
1562 },
1563 'permission_callback' => function() {
1564 return current_user_can( 'edit_posts' );
1565 },
1566 'meta' => [
1567 'mcp' => [ 'public' => true, 'type' => 'tool' ],
1568 'annotations' => [
1569 'readonly' => false,
1570 'destructive' => false,
1571 'idempotent' => true,
1572 ],
1573 ],
1574 ] );
1575
1576 // ---- delete-content ----
1577 wp_register_ability( 'atarim/delete-content', [
1578 'label' => 'Delete Content',
1579 'description' => 'Moves a post, page, or custom post type item to trash by default. Pass force: true to permanently delete (skips trash, irreversible).',
1580 'category' => 'atarim',
1581 'input_schema' => [
1582 'type' => 'object',
1583 'properties' => [
1584 'id' => [
1585 'type' => 'integer',
1586 'description' => 'Post ID. Required.',
1587 'minimum' => 1,
1588 ],
1589 'force' => [
1590 'type' => 'boolean',
1591 'description' => 'If true, permanently delete (bypass trash). Defaults to false (move to trash).',
1592 'default' => false,
1593 ],
1594 ],
1595 'required' => [ 'id' ],
1596 'additionalProperties' => false,
1597 ],
1598 'output_schema' => [
1599 'type' => 'object',
1600 'properties' => [
1601 'success' => [ 'type' => 'boolean' ],
1602 'id' => [ 'type' => 'integer' ],
1603 'post_type' => [ 'type' => 'string' ],
1604 'action' => [ 'type' => 'string' ],
1605 'message' => [ 'type' => 'string' ],
1606 ],
1607 'required' => [ 'success', 'message' ],
1608 ],
1609 'execute_callback' => function( $input = [] ) {
1610 $id = isset( $input['id'] ) ? (int) $input['id'] : 0;
1611 if ( $id <= 0 ) {
1612 return [
1613 'success' => false,
1614 'message' => 'id is required and must be a positive integer.',
1615 ];
1616 }
1617
1618 $force = ! empty( $input['force'] );
1619
1620 $post = get_post( $id );
1621 if ( ! $post ) {
1622 return [
1623 'success' => false,
1624 'message' => sprintf( 'Post %d not found.', $id ),
1625 ];
1626 }
1627
1628 // Per-post-type capability check.
1629 $pt_obj = get_post_type_object( $post->post_type );
1630 if ( $pt_obj && ! current_user_can( $pt_obj->cap->delete_post, $id ) ) {
1631 return [
1632 'success' => false,
1633 'message' => sprintf( 'You do not have permission to delete this %s.', $post->post_type ),
1634 ];
1635 }
1636
1637 $post_type = $post->post_type;
1638
1639 if ( $force ) {
1640 $result = wp_delete_post( $id, true );
1641
1642 if ( ! $result ) {
1643 return [
1644 'success' => false,
1645 'id' => $id,
1646 'post_type' => $post_type,
1647 'action' => 'force_delete',
1648 'message' => 'Permanent delete failed.',
1649 ];
1650 }
1651
1652 return [
1653 'success' => true,
1654 'id' => $id,
1655 'post_type' => $post_type,
1656 'action' => 'force_delete',
1657 'message' => 'Post permanently deleted.',
1658 ];
1659 }
1660
1661 // Trash path — wp_trash_post handles post types that support trash;
1662 // for those that don't (e.g. some CPTs registered without trash support),
1663 // it falls back to wp_delete_post internally.
1664 if ( $post->post_status === 'trash' ) {
1665 return [
1666 'success' => false,
1667 'id' => $id,
1668 'post_type' => $post_type,
1669 'action' => 'trash',
1670 'message' => 'Post is already in trash. Use force: true to permanently delete.',
1671 ];
1672 }
1673
1674 $result = wp_trash_post( $id );
1675
1676 if ( ! $result ) {
1677 return [
1678 'success' => false,
1679 'id' => $id,
1680 'post_type' => $post_type,
1681 'action' => 'trash',
1682 'message' => 'Move to trash failed.',
1683 ];
1684 }
1685
1686 return [
1687 'success' => true,
1688 'id' => $id,
1689 'post_type' => $post_type,
1690 'action' => 'trash',
1691 'message' => 'Post moved to trash.',
1692 ];
1693 },
1694 'permission_callback' => function() {
1695 return current_user_can( 'delete_posts' );
1696 },
1697 'meta' => [
1698 'mcp' => [ 'public' => true, 'type' => 'tool' ],
1699 'annotations' => [
1700 'readonly' => false,
1701 'destructive' => true,
1702 'idempotent' => false,
1703 ],
1704 ],
1705 ] );
1706
1707 // ---- list-revisions ----
1708 wp_register_ability( 'atarim/list-revisions', [
1709 'label' => 'List Revisions',
1710 'description' => 'Returns the revision history for a post, page, or custom post type item. Newest revision first. Each revision includes its ID, the author who saved it, the timestamp, the title and a content_preview (first ~200 chars). Pass include_content: true to also return full revision bodies — useful when the AI needs to diff revisions, but response size grows. Revisions are WordPress\'s automatic save history; not all post types track revisions (post and page do by default).',
1711 'category' => 'atarim',
1712 'input_schema' => [
1713 'type' => 'object',
1714 'properties' => [
1715 'id' => [
1716 'type' => 'integer',
1717 'description' => 'Parent post ID.',
1718 'minimum' => 1,
1719 ],
1720 'limit' => [
1721 'type' => 'integer',
1722 'description' => 'Max revisions to return. -1 for all. Defaults to 20.',
1723 'default' => 20,
1724 'minimum' => -1,
1725 ],
1726 'include_content' => [
1727 'type' => 'boolean',
1728 'description' => 'Include the full content of each revision under "content". Defaults to false; only a short content_preview is returned per revision.',
1729 'default' => false,
1730 ],
1731 ],
1732 'required' => [ 'id' ],
1733 'additionalProperties' => false,
1734 ],
1735 'output_schema' => [
1736 'type' => 'object',
1737 'properties' => [
1738 'success' => [ 'type' => 'boolean' ],
1739 'post_id' => [ 'type' => 'integer' ],
1740 'total' => [ 'type' => 'integer' ],
1741 'returned' => [ 'type' => 'integer' ],
1742 'revisions' => [
1743 'type' => 'array',
1744 'items' => [
1745 'type' => 'object',
1746 'properties' => [
1747 'revision_id' => [ 'type' => 'integer' ],
1748 'date' => [ 'type' => 'string' ],
1749 'author' => [ 'type' => 'integer' ],
1750 'author_name' => [ 'type' => 'string' ],
1751 'title' => [ 'type' => 'string' ],
1752 'excerpt' => [ 'type' => 'string' ],
1753 'content_preview' => [ 'type' => 'string' ],
1754 'content' => [ 'type' => 'string' ],
1755 ],
1756 ],
1757 ],
1758 'message' => [ 'type' => 'string' ],
1759 ],
1760 'required' => [ 'success', 'post_id', 'total', 'returned', 'revisions' ],
1761 ],
1762 'execute_callback' => function( $input = [] ) {
1763 $id = isset( $input['id'] ) ? (int) $input['id'] : 0;
1764 if ( $id <= 0 ) {
1765 return [
1766 'success' => false,
1767 'post_id' => 0,
1768 'total' => 0,
1769 'returned' => 0,
1770 'revisions' => [],
1771 'message' => 'id is required and must be a positive integer.',
1772 ];
1773 }
1774
1775 $post = get_post( $id );
1776 if ( ! $post ) {
1777 return [
1778 'success' => false,
1779 'post_id' => $id,
1780 'total' => 0,
1781 'returned' => 0,
1782 'revisions' => [],
1783 'message' => sprintf( 'Post %d not found.', $id ),
1784 ];
1785 }
1786
1787 $pt_obj = get_post_type_object( $post->post_type );
1788 if ( $pt_obj && ! current_user_can( $pt_obj->cap->edit_post, $id ) ) {
1789 return [
1790 'success' => false,
1791 'post_id' => $id,
1792 'total' => 0,
1793 'returned' => 0,
1794 'revisions' => [],
1795 'message' => sprintf( 'You do not have permission to read revisions for this %s.', $post->post_type ),
1796 ];
1797 }
1798
1799 $limit = isset( $input['limit'] ) ? (int) $input['limit'] : 20;
1800 $include_content = ! empty( $input['include_content'] );
1801
1802 // wp_get_post_revisions returns newest-first by default. Auto-draft revisions
1803 // are included; we filter those out as they're noise for an AI caller.
1804 $args = [];
1805 if ( $limit > 0 ) {
1806 $args['posts_per_page'] = $limit;
1807 }
1808 $all = wp_get_post_revisions( $id, $args );
1809
1810 $revisions = [];
1811 foreach ( $all as $rev ) {
1812 // Skip autosaves — not part of the human-visible revision history.
1813 if ( wp_is_post_autosave( $rev ) ) {
1814 continue;
1815 }
1816
1817 $author_obj = get_userdata( (int) $rev->post_author );
1818 $author_name = $author_obj ? $author_obj->display_name : '';
1819
1820 $stripped = trim( wp_strip_all_tags( strip_shortcodes( $rev->post_content ) ) );
1821 $stripped = preg_replace( '/\s+/', ' ', $stripped );
1822 $content_preview = ( strlen( $stripped ) > 200 )
1823 ? substr( $stripped, 0, 200 ) . ''
1824 : $stripped;
1825
1826 $entry = [
1827 'revision_id' => (int) $rev->ID,
1828 'date' => $rev->post_date_gmt,
1829 'author' => (int) $rev->post_author,
1830 'author_name' => $author_name,
1831 'title' => $rev->post_title,
1832 'excerpt' => $rev->post_excerpt,
1833 'content_preview' => $content_preview,
1834 ];
1835 if ( $include_content ) {
1836 $entry['content'] = $rev->post_content;
1837 }
1838 $revisions[] = $entry;
1839 }
1840
1841 return [
1842 'success' => true,
1843 'post_id' => $id,
1844 'total' => count( $revisions ),
1845 'returned' => count( $revisions ),
1846 'revisions' => $revisions,
1847 'message' => sprintf( '%d revision(s) found for post %d.', count( $revisions ), $id ),
1848 ];
1849 },
1850 'permission_callback' => function() {
1851 return current_user_can( 'edit_posts' );
1852 },
1853 'meta' => [
1854 'mcp' => [ 'public' => true, 'type' => 'tool' ],
1855 'annotations' => [
1856 'readonly' => true,
1857 'destructive' => false,
1858 'idempotent' => true,
1859 ],
1860 ],
1861 ] );
1862
1863 // ---- restore-revision ----
1864 wp_register_ability( 'atarim/restore-revision', [
1865 'label' => 'Restore Revision',
1866 'description' => 'Restores a post to the state captured in a prior revision. The revision_id is taken from list-revisions output. The current post content is replaced by the revision\'s content; WordPress typically captures the pre-restore state as a new revision in the chain (so the operation is not destructive in the catastrophic sense), but the AI should not rely on that for rollback safety and should call list-revisions before AND after to confirm.',
1867 'category' => 'atarim',
1868 'input_schema' => [
1869 'type' => 'object',
1870 'properties' => [
1871 'revision_id' => [
1872 'type' => 'integer',
1873 'description' => 'Revision ID to restore. Obtained from list-revisions.',
1874 'minimum' => 1,
1875 ],
1876 ],
1877 'required' => [ 'revision_id' ],
1878 'additionalProperties' => false,
1879 ],
1880 'output_schema' => [
1881 'type' => 'object',
1882 'properties' => [
1883 'success' => [ 'type' => 'boolean' ],
1884 'revision_id' => [ 'type' => 'integer' ],
1885 'post_id' => [ 'type' => 'integer' ],
1886 'title' => [ 'type' => 'string' ],
1887 'restored_from_date' => [ 'type' => 'string' ],
1888 'message' => [ 'type' => 'string' ],
1889 ],
1890 'required' => [ 'success', 'message' ],
1891 ],
1892 'execute_callback' => function( $input = [] ) {
1893 $revision_id = isset( $input['revision_id'] ) ? (int) $input['revision_id'] : 0;
1894 if ( $revision_id <= 0 ) {
1895 return [
1896 'success' => false,
1897 'revision_id' => 0,
1898 'post_id' => 0,
1899 'message' => 'revision_id is required and must be a positive integer.',
1900 ];
1901 }
1902
1903 $revision = wp_get_post_revision( $revision_id );
1904 if ( ! $revision ) {
1905 return [
1906 'success' => false,
1907 'revision_id' => $revision_id,
1908 'post_id' => 0,
1909 'message' => sprintf( 'Revision %d not found.', $revision_id ),
1910 ];
1911 }
1912
1913 $parent_id = (int) $revision->post_parent;
1914 $parent = get_post( $parent_id );
1915 if ( ! $parent ) {
1916 return [
1917 'success' => false,
1918 'revision_id' => $revision_id,
1919 'post_id' => $parent_id,
1920 'message' => sprintf( 'Parent post %d for revision %d no longer exists.', $parent_id, $revision_id ),
1921 ];
1922 }
1923
1924 $pt_obj = get_post_type_object( $parent->post_type );
1925 if ( $pt_obj && ! current_user_can( $pt_obj->cap->edit_post, $parent_id ) ) {
1926 return [
1927 'success' => false,
1928 'revision_id' => $revision_id,
1929 'post_id' => $parent_id,
1930 'message' => sprintf( 'You do not have permission to restore revisions for this %s.', $parent->post_type ),
1931 ];
1932 }
1933
1934 $result = wp_restore_post_revision( $revision_id );
1935
1936 if ( is_wp_error( $result ) ) {
1937 return [
1938 'success' => false,
1939 'revision_id' => $revision_id,
1940 'post_id' => $parent_id,
1941 'message' => 'Restore failed: ' . $result->get_error_message(),
1942 ];
1943 }
1944
1945 if ( $result === null || $result === false ) {
1946 return [
1947 'success' => false,
1948 'revision_id' => $revision_id,
1949 'post_id' => $parent_id,
1950 'message' => 'Restore failed: WordPress reported the operation did not complete.',
1951 ];
1952 }
1953
1954 $restored_post = get_post( $parent_id );
1955
1956 return [
1957 'success' => true,
1958 'revision_id' => $revision_id,
1959 'post_id' => $parent_id,
1960 'title' => $restored_post ? $restored_post->post_title : '',
1961 'restored_from_date' => $revision->post_date_gmt,
1962 'message' => sprintf( 'Post %d restored from revision %d (dated %s).', $parent_id, $revision_id, $revision->post_date_gmt ),
1963 ];
1964 },
1965 'permission_callback' => function() {
1966 return current_user_can( 'edit_posts' );
1967 },
1968 'meta' => [
1969 'mcp' => [ 'public' => true, 'type' => 'tool' ],
1970 'annotations' => [
1971 'readonly' => false,
1972 'destructive' => true,
1973 'idempotent' => true,
1974 ],
1975 ],
1976 ] );
1977
1978 // ---- duplicate-post ----
1979 wp_register_ability( 'atarim/duplicate-post', [
1980 'label' => 'Duplicate Post',
1981 'description' => 'Duplicates a post, page, or custom post type item. Copies core fields, all post meta (including page-builder payloads such as Elementor/Bricks data) except editing-lock and old-slug keys, and all taxonomy terms (categories, tags, custom taxonomies). The featured image is shared (same attachment). The new item is set to draft status with " (Copy)" appended to the title and a unique slug, and the acting user becomes the author. Comments are not copied. Single item only: child posts and attachments are not duplicated. Only public, non-internal post types are allowed. Returns the new post id, slug, status, title, and edit/preview links.',
1982 'category' => 'atarim',
1983 'input_schema' => [
1984 'type' => 'object',
1985 'properties' => [
1986 'post_id' => [
1987 'type' => 'integer',
1988 'description' => 'ID of the post, page, or custom post type item to duplicate.',
1989 ],
1990 ],
1991 'required' => [ 'post_id' ],
1992 'additionalProperties' => false,
1993 ],
1994 'output_schema' => [
1995 'type' => 'object',
1996 'properties' => [
1997 'success' => [ 'type' => 'boolean' ],
1998 'new_post_id' => [ 'type' => 'integer' ],
1999 'source_post_id' => [ 'type' => 'integer' ],
2000 'new_slug' => [ 'type' => 'string' ],
2001 'status' => [ 'type' => 'string' ],
2002 'title' => [ 'type' => 'string' ],
2003 'edit_link' => [ 'type' => 'string' ],
2004 'preview_link' => [ 'type' => 'string' ],
2005 'message' => [ 'type' => 'string' ],
2006 ],
2007 'required' => [ 'success', 'message' ],
2008 ],
2009 'execute_callback' => function( $input ) {
2010 $source_id = isset( $input['post_id'] ) ? absint( $input['post_id'] ) : 0;
2011 if ( ! $source_id ) {
2012 return [ 'success' => false, 'message' => 'A valid post_id is required.' ];
2013 }
2014
2015 $source = get_post( $source_id );
2016 if ( ! $source ) {
2017 return [ 'success' => false, 'message' => sprintf( 'Post %d not found.', $source_id ) ];
2018 }
2019
2020 $post_type = $source->post_type;
2021 $pt_obj = get_post_type_object( $post_type );
2022 if ( ! $pt_obj ) {
2023 return [ 'success' => false, 'message' => sprintf( 'Unknown post type "%s".', $post_type ) ];
2024 }
2025
2026 // Block internal / non-public post types.
2027 $excluded = [
2028 'attachment', 'revision', 'nav_menu_item', 'custom_css',
2029 'customize_changeset', 'oembed_cache', 'user_request', 'wp_block',
2030 'wp_template', 'wp_template_part', 'wp_global_styles', 'wp_navigation',
2031 ];
2032 if ( in_array( $post_type, $excluded, true ) || empty( $pt_obj->public ) ) {
2033 return [ 'success' => false, 'message' => sprintf( 'Post type "%s" cannot be duplicated (internal or non-public).', $post_type ) ];
2034 }
2035
2036 // Capability: must be able to edit the source and create the target type.
2037 if ( ! current_user_can( $pt_obj->cap->edit_post, $source_id ) ) {
2038 return [ 'success' => false, 'message' => 'You do not have permission to duplicate this post.' ];
2039 }
2040 if ( ! current_user_can( $pt_obj->cap->create_posts ) ) {
2041 return [ 'success' => false, 'message' => sprintf( 'You do not have permission to create %s items.', $post_type ) ];
2042 }
2043
2044 // Build the new title + a unique draft slug (wp_insert_post does not
2045 // uniquify slugs for drafts, so compute it explicitly).
2046 $new_title = ( '' !== $source->post_title ) ? $source->post_title . ' (Copy)' : '(Copy)';
2047 $desired_slug = sanitize_title( $new_title );
2048 $unique_slug = wp_unique_post_slug( $desired_slug, 0, 'draft', $post_type, (int) $source->post_parent );
2049
2050 $acting_user = get_current_user_id();
2051
2052 $postarr = [
2053 'post_title' => $new_title,
2054 'post_name' => $unique_slug,
2055 'post_content' => $source->post_content,
2056 'post_excerpt' => $source->post_excerpt,
2057 'post_status' => 'draft',
2058 'post_type' => $post_type,
2059 'post_author' => $acting_user ? $acting_user : (int) $source->post_author,
2060 'post_parent' => (int) $source->post_parent,
2061 'menu_order' => (int) $source->menu_order,
2062 'comment_status' => $source->comment_status,
2063 'ping_status' => $source->ping_status,
2064 'post_password' => $source->post_password,
2065 ];
2066
2067 $new_id = wp_insert_post( wp_slash( $postarr ), true );
2068 if ( is_wp_error( $new_id ) ) {
2069 return [ 'success' => false, 'message' => 'Duplicate failed: ' . $new_id->get_error_message() ];
2070 }
2071
2072 // Copy taxonomy terms for every taxonomy on this post type
2073 // (includes categories, tags, custom taxonomies and post_format).
2074 foreach ( get_object_taxonomies( $post_type ) as $taxonomy ) {
2075 $term_ids = wp_get_object_terms( $source_id, $taxonomy, [ 'fields' => 'ids' ] );
2076 if ( ! is_wp_error( $term_ids ) && ! empty( $term_ids ) ) {
2077 wp_set_object_terms( $new_id, $term_ids, $taxonomy, false );
2078 }
2079 }
2080
2081 // Copy post meta (multi-value safe). _thumbnail_id is copied here, so the
2082 // featured image is shared. Editing-lock and old-slug keys are skipped.
2083 $skip_meta = [ '_edit_lock', '_edit_last', '_wp_old_slug', '_wp_old_date' ];
2084 $all_meta = get_post_meta( $source_id );
2085 if ( is_array( $all_meta ) ) {
2086 foreach ( $all_meta as $meta_key => $meta_values ) {
2087 if ( in_array( $meta_key, $skip_meta, true ) ) {
2088 continue;
2089 }
2090 foreach ( (array) $meta_values as $meta_value ) {
2091 add_post_meta( $new_id, $meta_key, wp_slash( maybe_unserialize( $meta_value ) ) );
2092 }
2093 }
2094 }
2095
2096 $new_post = get_post( $new_id );
2097 $preview_link = get_preview_post_link( $new_id );
2098
2099 return [
2100 'success' => true,
2101 'new_post_id' => (int) $new_id,
2102 'source_post_id' => $source_id,
2103 'new_slug' => $new_post ? $new_post->post_name : $unique_slug,
2104 'status' => 'draft',
2105 'title' => $new_title,
2106 'edit_link' => admin_url( 'post.php?post=' . (int) $new_id . '&action=edit' ),
2107 'preview_link' => $preview_link ? $preview_link : '',
2108 'message' => sprintf( 'Duplicated post %d as draft %d ("%s").', $source_id, (int) $new_id, $new_title ),
2109 ];
2110 },
2111 'permission_callback' => function() {
2112 return current_user_can( 'edit_posts' );
2113 },
2114 'meta' => [
2115 'mcp' => [ 'public' => true, 'type' => 'tool' ],
2116 'annotations' => [
2117 'readonly' => false,
2118 'destructive' => false,
2119 'idempotent' => false,
2120 ],
2121 ],
2122 ] );
2123 }
2124 }