PluginProbe ʕ •ᴥ•ʔ
Atarim – AI Agency for WordPress: Edit Pages, Fix Code, Update Plugins, SEO & Client Feedback / 5.1
Atarim – AI Agency for WordPress: Edit Pages, Fix Code, Update Plugins, SEO & Client Feedback v5.1
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 4 weeks ago class-avcf-abilities-core.php 4 weeks ago class-avcf-abilities-global-styles.php 4 weeks ago class-avcf-abilities-gutenberg.php 4 weeks ago class-avcf-abilities-media.php 4 weeks ago class-avcf-abilities-metadata.php 4 weeks ago class-avcf-abilities-navigation.php 4 weeks ago class-avcf-abilities-patterns.php 4 weeks ago class-avcf-abilities-plugins.php 4 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 4 weeks ago class-avcf-abilities-themes.php 4 weeks ago class-avcf-abilities-users.php 4 weeks ago
class-avcf-abilities-content.php
2063 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.',
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 return [
435 'total' => (int) $query->found_posts,
436 'returned' => count( $items ),
437 'offset' => $offset,
438 'post_type' => $post_type,
439 'items' => $items,
440 ];
441 },
442 'permission_callback' => function() {
443 return current_user_can( 'edit_posts' );
444 },
445 'meta' => [
446 'mcp' => [ 'public' => true, 'type' => 'tool' ],
447 'annotations' => [
448 'readonly' => true,
449 'destructive' => false,
450 'idempotent' => true,
451 ],
452 ],
453 ] );
454
455 // ---- get-content ----
456 wp_register_ability( 'atarim/get-content', [
457 'label' => 'Get Content',
458 '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.',
459 'category' => 'atarim',
460 'input_schema' => [
461 'type' => 'object',
462 'properties' => [
463 'id' => [
464 'type' => 'integer',
465 'description' => 'Post ID.',
466 'minimum' => 1,
467 ],
468 'include_taxonomies' => [
469 'type' => 'boolean',
470 'description' => 'Include all assigned taxonomy terms (categories, tags, custom taxonomies) for the post.',
471 'default' => false,
472 ],
473 'include_meta_keys' => [
474 'type' => 'array',
475 '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.',
476 'items' => [ 'type' => 'string' ],
477 ],
478 ],
479 'required' => [ 'id' ],
480 'additionalProperties' => false,
481 ],
482 'output_schema' => [
483 'type' => 'object',
484 'properties' => [
485 'success' => [ 'type' => 'boolean' ],
486 'id' => [ 'type' => 'integer' ],
487 'title' => [ 'type' => 'string' ],
488 'slug' => [ 'type' => 'string' ],
489 'status' => [ 'type' => 'string' ],
490 'post_type' => [ 'type' => 'string' ],
491 'url' => [ 'type' => 'string' ],
492 'content' => [ 'type' => 'string' ],
493 'excerpt' => [ 'type' => 'string' ],
494 'date' => [ 'type' => 'string' ],
495 'created' => [ 'type' => 'string' ],
496 'modified' => [ 'type' => 'string' ],
497 'author' => [ 'type' => 'integer' ],
498 'parent' => [ 'type' => 'integer' ],
499 'featured_media' => [ 'type' => 'integer' ],
500 'comment_status' => [ 'type' => 'string' ],
501 'ping_status' => [ 'type' => 'string' ],
502 'taxonomies' => [ 'type' => 'object' ],
503 'meta' => [ 'type' => 'object' ],
504 'message' => [ 'type' => 'string' ],
505 ],
506 'required' => [ 'success' ],
507 ],
508 'execute_callback' => function( $input = [] ) {
509 $id = isset( $input['id'] ) ? (int) $input['id'] : 0;
510 if ( $id <= 0 ) {
511 return [ 'success' => false, 'message' => 'id is required and must be a positive integer.' ];
512 }
513
514 $post = get_post( $id );
515 if ( ! $post ) {
516 return [ 'success' => false, 'message' => sprintf( 'Post %d not found.', $id ) ];
517 }
518
519 $pt_obj = get_post_type_object( $post->post_type );
520 if ( $pt_obj && ! current_user_can( $pt_obj->cap->read_post, $id ) ) {
521 return [ 'success' => false, 'message' => sprintf( 'You do not have permission to read this %s.', $post->post_type ) ];
522 }
523
524 $result = [
525 'success' => true,
526 'id' => $post->ID,
527 'title' => $post->post_title,
528 'slug' => $post->post_name,
529 'status' => $post->post_status,
530 'post_type' => $post->post_type,
531 'url' => get_permalink( $post->ID ),
532 'content' => $post->post_content,
533 'excerpt' => $post->post_excerpt,
534 'date' => $post->post_date,
535 'created' => $post->post_date_gmt,
536 'modified' => $post->post_modified_gmt,
537 'author' => (int) $post->post_author,
538 'parent' => (int) $post->post_parent,
539 'featured_media' => (int) get_post_thumbnail_id( $post->ID ),
540 'comment_status' => $post->comment_status,
541 'ping_status' => $post->ping_status,
542 'message' => 'OK.',
543 ];
544
545 if ( ! empty( $input['include_taxonomies'] ) ) {
546 $taxonomies = get_object_taxonomies( $post->post_type, 'names' );
547 $tax_assignments = [];
548 foreach ( $taxonomies as $tax ) {
549 $terms = get_the_terms( $post->ID, $tax );
550 if ( is_wp_error( $terms ) || empty( $terms ) ) {
551 $tax_assignments[ $tax ] = [];
552 continue;
553 }
554 $tax_assignments[ $tax ] = array_map(
555 function( $t ) {
556 return [
557 'term_id' => (int) $t->term_id,
558 'slug' => $t->slug,
559 'name' => $t->name,
560 ];
561 },
562 $terms
563 );
564 }
565 $result['taxonomies'] = $tax_assignments;
566 }
567
568 if ( ! empty( $input['include_meta_keys'] ) && is_array( $input['include_meta_keys'] ) ) {
569 $meta = [];
570 foreach ( $input['include_meta_keys'] as $key ) {
571 $key = (string) $key;
572 // Skip private/internal meta even if explicitly requested.
573 if ( $key === '' || strpos( $key, '_' ) === 0 ) {
574 continue;
575 }
576 $value = get_post_meta( $post->ID, $key, true );
577 $meta[ $key ] = $value;
578 }
579 $result['meta'] = $meta;
580 }
581
582 return $result;
583 },
584 'permission_callback' => function() {
585 return current_user_can( 'edit_posts' );
586 },
587 'meta' => [
588 'mcp' => [ 'public' => true, 'type' => 'tool' ],
589 'annotations' => [
590 'readonly' => true,
591 'destructive' => false,
592 'idempotent' => true,
593 ],
594 ],
595 ] );
596
597 // ---- create-content ----
598 wp_register_ability( 'atarim/create-content', [
599 'label' => 'Create Content',
600 '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.',
601 'category' => 'atarim',
602 'input_schema' => [
603 'type' => 'object',
604 'properties' => [
605 'post_type' => [
606 'type' => 'string',
607 'description' => 'Post type slug (e.g. "post", "page", or a custom slug).',
608 'minLength' => 1,
609 ],
610 'title' => [
611 'type' => 'string',
612 'description' => 'Post title.',
613 'minLength' => 1,
614 ],
615 'slug' => [
616 'type' => 'string',
617 'description' => 'Desired URL slug. Omit to let WordPress generate one from the title. WordPress auto-suffixes on conflict (slug-2, slug-3).',
618 'minLength' => 1,
619 ],
620 'status' => [
621 'type' => 'string',
622 'description' => 'Publish status. Defaults to "draft". Use "future" together with a date in the future to schedule.',
623 'enum' => [ 'publish', 'draft', 'pending', 'private', 'future' ],
624 'default' => 'draft',
625 ],
626 'content' => [
627 'type' => 'string',
628 '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.',
629 ],
630 'content_url' => [
631 'type' => 'string',
632 '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.',
633 ],
634 'content_format' => [
635 'type' => 'string',
636 '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.',
637 'enum' => [ 'auto', 'raw', 'blocks' ],
638 'default' => 'auto',
639 ],
640 'excerpt' => [
641 'type' => 'string',
642 'description' => 'Hand-written excerpt. Omit to let WordPress generate one from the content.',
643 ],
644 'date' => [
645 'type' => 'string',
646 '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.',
647 ],
648 'author' => [
649 'type' => 'integer',
650 '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.',
651 'minimum' => 1,
652 ],
653 'parent' => [
654 'type' => 'integer',
655 'description' => 'Parent post ID for hierarchical post types (pages, custom hierarchical CPTs). 0 means no parent.',
656 'minimum' => 0,
657 ],
658 'featured_media' => [
659 'type' => 'integer',
660 '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).',
661 'minimum' => 1,
662 ],
663 'comment_status' => [
664 'type' => 'string',
665 'description' => 'Whether comments are allowed. Defaults to the site-wide setting.',
666 'enum' => [ 'open', 'closed' ],
667 ],
668 'ping_status' => [
669 'type' => 'string',
670 'description' => 'Whether pingbacks and trackbacks are allowed. Defaults to the site-wide setting.',
671 'enum' => [ 'open', 'closed' ],
672 ],
673 ],
674 'required' => [ 'post_type', 'title' ],
675 'additionalProperties' => false,
676 ],
677 'output_schema' => [
678 'type' => 'object',
679 'properties' => [
680 'success' => [ 'type' => 'boolean' ],
681 'id' => [ 'type' => 'integer' ],
682 'title' => [ 'type' => 'string' ],
683 'slug' => [ 'type' => 'string' ],
684 'status' => [ 'type' => 'string' ],
685 'post_type' => [ 'type' => 'string' ],
686 'url' => [ 'type' => 'string' ],
687 'excerpt' => [ 'type' => 'string' ],
688 'date' => [ 'type' => 'string' ],
689 'author' => [ 'type' => 'integer' ],
690 'parent' => [ 'type' => 'integer' ],
691 'featured_media' => [ 'type' => 'integer' ],
692 'comment_status' => [ 'type' => 'string' ],
693 'ping_status' => [ 'type' => 'string' ],
694 'message' => [ 'type' => 'string' ],
695 ],
696 'required' => [ 'success', 'message' ],
697 ],
698 'execute_callback' => function( $input = [] ) {
699 $post_type = isset( $input['post_type'] ) ? sanitize_key( $input['post_type'] ) : '';
700 $title = isset( $input['title'] ) ? sanitize_text_field( $input['title'] ) : '';
701
702 if ( empty( $post_type ) || empty( $title ) ) {
703 return [
704 'success' => false,
705 'message' => 'post_type and title are required.',
706 ];
707 }
708
709 if ( ! post_type_exists( $post_type ) ) {
710 return [
711 'success' => false,
712 'message' => sprintf( 'Post type "%s" does not exist on this site.', $post_type ),
713 ];
714 }
715
716 if ( in_array( $post_type, $this->avcf_write_protected_post_types(), true ) ) {
717 return [
718 'success' => false,
719 'message' => sprintf(
720 'Post type "%s" cannot be created through this ability (internal/structural type).%s',
721 $post_type,
722 $this->avcf_write_protected_hint( $post_type )
723 ),
724 ];
725 }
726
727 $pt_obj = get_post_type_object( $post_type );
728 if ( $pt_obj && ! current_user_can( $pt_obj->cap->edit_posts ) ) {
729 return [
730 'success' => false,
731 'message' => sprintf( 'You do not have permission to create %s.', $post_type ),
732 ];
733 }
734
735 // Status — default to draft, validate enum.
736 $allowed_statuses = [ 'publish', 'draft', 'pending', 'private', 'future' ];
737 $status = isset( $input['status'] ) ? sanitize_key( $input['status'] ) : 'draft';
738 if ( ! in_array( $status, $allowed_statuses, true ) ) {
739 $status = 'draft';
740 }
741
742 // Publishing requires the publish capability for this post type.
743 if ( in_array( $status, [ 'publish', 'future', 'private' ], true ) ) {
744 if ( $pt_obj && ! current_user_can( $pt_obj->cap->publish_posts ) ) {
745 return [
746 'success' => false,
747 'message' => sprintf( 'You do not have permission to publish %s.', $post_type ),
748 ];
749 }
750 }
751
752 $postarr = [
753 'post_type' => $post_type,
754 'post_title' => $title,
755 'post_status' => $status,
756 ];
757
758 // Slug — optional; let wp_insert_post auto-generate from title if omitted.
759 if ( isset( $input['slug'] ) && $input['slug'] !== '' ) {
760 $postarr['post_name'] = sanitize_title( $input['slug'] );
761 }
762
763 // Content body — inline (content) or pulled from a URL (content_url).
764 $has_content = array_key_exists( 'content', $input );
765 $has_content_url = isset( $input['content_url'] ) && trim( (string) $input['content_url'] ) !== '';
766 if ( $has_content && $has_content_url ) {
767 return [
768 'success' => false,
769 'message' => 'Provide either content or content_url, not both.',
770 ];
771 }
772 if ( $has_content || $has_content_url ) {
773 $format = isset( $input['content_format'] ) ? (string) $input['content_format'] : 'auto';
774 if ( $has_content_url ) {
775 list( $fetched_content, $fetch_err ) = $this->avcf_fetch_content_from_url( (string) $input['content_url'] );
776 if ( $fetch_err !== null ) {
777 return [ 'success' => false, 'message' => $fetch_err ];
778 }
779 $raw_content = $fetched_content;
780 } else {
781 $raw_content = (string) $input['content'];
782 }
783 $postarr['post_content'] = $this->avcf_prepare_content_body( $raw_content, $format );
784 }
785
786 // Excerpt.
787 if ( isset( $input['excerpt'] ) ) {
788 $postarr['post_excerpt'] = sanitize_textarea_field( (string) $input['excerpt'] );
789 }
790
791 // Date — parse, normalise, validate "future" constraint.
792 if ( isset( $input['date'] ) && $input['date'] !== '' ) {
793 list( $local_date, $gmt_date, $date_err ) = $this->avcf_normalize_post_date( (string) $input['date'] );
794 if ( $date_err !== null ) {
795 return [ 'success' => false, 'message' => $date_err ];
796 }
797 if ( $status === 'future' && strtotime( $gmt_date ) <= time() ) {
798 return [
799 'success' => false,
800 'message' => 'status="future" requires a date in the future.',
801 ];
802 }
803 $postarr['post_date'] = $local_date;
804 $postarr['post_date_gmt'] = $gmt_date;
805 }
806
807 // Author — defaults to current user; requires edit_others_posts to set someone else.
808 if ( isset( $input['author'] ) ) {
809 $author_id = (int) $input['author'];
810 if ( $author_id <= 0 ) {
811 return [ 'success' => false, 'message' => 'author must be a positive user ID.' ];
812 }
813 if ( ! get_userdata( $author_id ) ) {
814 return [ 'success' => false, 'message' => sprintf( 'User %d does not exist.', $author_id ) ];
815 }
816 if ( $author_id !== get_current_user_id() ) {
817 if ( $pt_obj && ! current_user_can( $pt_obj->cap->edit_others_posts ) ) {
818 return [
819 'success' => false,
820 'message' => sprintf( 'You do not have permission to assign %s to another author.', $post_type ),
821 ];
822 }
823 }
824 $postarr['post_author'] = $author_id;
825 }
826
827 // Parent — validate hierarchical and that the parent exists.
828 if ( isset( $input['parent'] ) ) {
829 $parent_id = (int) $input['parent'];
830 if ( $parent_id < 0 ) {
831 return [ 'success' => false, 'message' => 'parent must be 0 or a positive post ID.' ];
832 }
833 if ( $parent_id > 0 ) {
834 if ( ! is_post_type_hierarchical( $post_type ) ) {
835 return [
836 'success' => false,
837 'message' => sprintf( 'Post type "%s" is not hierarchical; parent must be 0.', $post_type ),
838 ];
839 }
840 $parent_post = get_post( $parent_id );
841 if ( ! $parent_post || $parent_post->post_type !== $post_type ) {
842 return [
843 'success' => false,
844 'message' => sprintf( 'Parent %d does not exist or is not a %s.', $parent_id, $post_type ),
845 ];
846 }
847 }
848 $postarr['post_parent'] = $parent_id;
849 }
850
851 // Featured media — validate existence + is-image. Stored via _thumbnail_id meta after insert.
852 $featured_media_id = null;
853 if ( isset( $input['featured_media'] ) ) {
854 $featured_media_id = (int) $input['featured_media'];
855 $attach_err = $this->avcf_validate_attachment_id( $featured_media_id );
856 if ( $attach_err !== null ) {
857 return [ 'success' => false, 'message' => $attach_err ];
858 }
859 }
860
861 // Comment / ping status.
862 if ( isset( $input['comment_status'] ) ) {
863 $cs = sanitize_key( $input['comment_status'] );
864 if ( ! in_array( $cs, [ 'open', 'closed' ], true ) ) {
865 return [ 'success' => false, 'message' => 'comment_status must be "open" or "closed".' ];
866 }
867 $postarr['comment_status'] = $cs;
868 }
869 if ( isset( $input['ping_status'] ) ) {
870 $ps = sanitize_key( $input['ping_status'] );
871 if ( ! in_array( $ps, [ 'open', 'closed' ], true ) ) {
872 return [ 'success' => false, 'message' => 'ping_status must be "open" or "closed".' ];
873 }
874 $postarr['ping_status'] = $ps;
875 }
876
877 $post_id = wp_insert_post( $postarr, true );
878
879 if ( is_wp_error( $post_id ) ) {
880 return [
881 'success' => false,
882 'message' => 'Creation failed: ' . $post_id->get_error_message(),
883 ];
884 }
885
886 // Set featured image after insert (wp_insert_post does not accept _thumbnail_id directly).
887 if ( $featured_media_id !== null ) {
888 set_post_thumbnail( $post_id, $featured_media_id );
889 }
890
891 $post = get_post( $post_id );
892
893 return [
894 'success' => true,
895 'id' => $post_id,
896 'title' => $post->post_title,
897 'slug' => $post->post_name,
898 'status' => $post->post_status,
899 'post_type' => $post->post_type,
900 'url' => get_permalink( $post_id ),
901 'excerpt' => $post->post_excerpt,
902 'date' => $post->post_date,
903 'author' => (int) $post->post_author,
904 'parent' => (int) $post->post_parent,
905 'featured_media' => (int) get_post_thumbnail_id( $post_id ),
906 'comment_status' => $post->comment_status,
907 'ping_status' => $post->ping_status,
908 'message' => 'Content created.',
909 ];
910 },
911 'permission_callback' => function() {
912 return current_user_can( 'edit_posts' );
913 },
914 'meta' => [
915 'mcp' => [ 'public' => true, 'type' => 'tool' ],
916 'annotations' => [
917 'readonly' => false,
918 'destructive' => false,
919 'idempotent' => false,
920 ],
921 ],
922 ] );
923
924 // ---- update-content ----
925 wp_register_ability( 'atarim/update-content', [
926 'label' => 'Update Content',
927 '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.',
928 'category' => 'atarim',
929 'input_schema' => [
930 'type' => 'object',
931 'properties' => [
932 'id' => [
933 'type' => 'integer',
934 'description' => 'Post ID. Required.',
935 'minimum' => 1,
936 ],
937 'title' => [
938 'type' => 'string',
939 'description' => 'New title. Omit to leave unchanged.',
940 'minLength' => 1,
941 ],
942 'slug' => [
943 'type' => 'string',
944 'description' => 'New URL slug. WordPress auto-suffixes on conflict. Omit to leave unchanged.',
945 'minLength' => 1,
946 ],
947 'status' => [
948 'type' => 'string',
949 'description' => 'New publish status. Use "future" with a future-dated "date" to schedule. Omit to leave unchanged.',
950 'enum' => [ 'publish', 'draft', 'pending', 'private', 'future' ],
951 ],
952 'content' => [
953 'type' => 'string',
954 '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.',
955 ],
956 'content_url' => [
957 'type' => 'string',
958 '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.',
959 ],
960 'content_format' => [
961 'type' => 'string',
962 'description' => 'How to process the content (or fetched content_url) body. "auto" (default), "raw", or "blocks". See create-content for details.',
963 'enum' => [ 'auto', 'raw', 'blocks' ],
964 'default' => 'auto',
965 ],
966 'excerpt' => [
967 'type' => 'string',
968 'description' => 'New excerpt. Pass an empty string to clear. Omit to leave unchanged.',
969 ],
970 'date' => [
971 'type' => 'string',
972 'description' => 'New publish date in site-local time. ISO 8601 or strtotime()-parseable. Omit to leave unchanged.',
973 ],
974 'author' => [
975 'type' => 'integer',
976 'description' => 'New author user ID. Requires edit_others_posts capability if different from current author. Omit to leave unchanged.',
977 'minimum' => 1,
978 ],
979 'parent' => [
980 'type' => 'integer',
981 'description' => 'New parent post ID for hierarchical post types. 0 removes the parent. Omit to leave unchanged.',
982 'minimum' => 0,
983 ],
984 'featured_media' => [
985 'type' => 'integer',
986 'description' => 'New featured image attachment ID. Must exist and be an image. Pass 0 to remove the featured image. Omit to leave unchanged.',
987 'minimum' => 0,
988 ],
989 'comment_status' => [
990 'type' => 'string',
991 'description' => 'New comment status. Omit to leave unchanged.',
992 'enum' => [ 'open', 'closed' ],
993 ],
994 'ping_status' => [
995 'type' => 'string',
996 'description' => 'New pingback/trackback status. Omit to leave unchanged.',
997 'enum' => [ 'open', 'closed' ],
998 ],
999 ],
1000 'required' => [ 'id' ],
1001 'additionalProperties' => false,
1002 ],
1003 'output_schema' => [
1004 'type' => 'object',
1005 'properties' => [
1006 'success' => [ 'type' => 'boolean' ],
1007 'id' => [ 'type' => 'integer' ],
1008 'title' => [ 'type' => 'string' ],
1009 'slug' => [ 'type' => 'string' ],
1010 'status' => [ 'type' => 'string' ],
1011 'post_type' => [ 'type' => 'string' ],
1012 'url' => [ 'type' => 'string' ],
1013 'excerpt' => [ 'type' => 'string' ],
1014 'date' => [ 'type' => 'string' ],
1015 'author' => [ 'type' => 'integer' ],
1016 'parent' => [ 'type' => 'integer' ],
1017 'featured_media' => [ 'type' => 'integer' ],
1018 'comment_status' => [ 'type' => 'string' ],
1019 'ping_status' => [ 'type' => 'string' ],
1020 'updated' => [ 'type' => 'array', 'items' => [ 'type' => 'string' ] ],
1021 'message' => [ 'type' => 'string' ],
1022 ],
1023 'required' => [ 'success', 'message' ],
1024 ],
1025 'execute_callback' => function( $input = [] ) {
1026 $id = isset( $input['id'] ) ? (int) $input['id'] : 0;
1027 if ( $id <= 0 ) {
1028 return [
1029 'success' => false,
1030 'message' => 'id is required and must be a positive integer.',
1031 ];
1032 }
1033
1034 $post = get_post( $id );
1035 if ( ! $post ) {
1036 return [
1037 'success' => false,
1038 'message' => sprintf( 'Post %d not found.', $id ),
1039 ];
1040 }
1041
1042 if ( in_array( $post->post_type, $this->avcf_write_protected_post_types(), true ) ) {
1043 return [
1044 'success' => false,
1045 'message' => sprintf(
1046 'Post type "%s" cannot be edited through this ability (internal/structural type).%s',
1047 $post->post_type,
1048 $this->avcf_write_protected_hint( $post->post_type )
1049 ),
1050 ];
1051 }
1052
1053 $pt_obj = get_post_type_object( $post->post_type );
1054 if ( $pt_obj && ! current_user_can( $pt_obj->cap->edit_post, $id ) ) {
1055 return [
1056 'success' => false,
1057 'message' => sprintf( 'You do not have permission to edit this %s.', $post->post_type ),
1058 ];
1059 }
1060
1061 // Build the update payload from only the fields the caller actually sent.
1062 // wp_update_post leaves omitted fields untouched, but we track what we
1063 // changed so we can return a useful `updated` array.
1064 $update = [ 'ID' => $id ];
1065 $updated = [];
1066
1067 if ( array_key_exists( 'title', $input ) ) {
1068 $update['post_title'] = sanitize_text_field( (string) $input['title'] );
1069 $updated[] = 'title';
1070 }
1071
1072 if ( array_key_exists( 'slug', $input ) ) {
1073 $update['post_name'] = sanitize_title( (string) $input['slug'] );
1074 $updated[] = 'slug';
1075 }
1076
1077 // Status — needs publish_posts cap if moving into publish/future/private.
1078 if ( array_key_exists( 'status', $input ) ) {
1079 $status = sanitize_key( (string) $input['status'] );
1080 $allowed = [ 'publish', 'draft', 'pending', 'private', 'future' ];
1081 if ( ! in_array( $status, $allowed, true ) ) {
1082 return [
1083 'success' => false,
1084 'message' => sprintf( 'Invalid status "%s". Allowed: publish, draft, pending, private, future.', $status ),
1085 ];
1086 }
1087 if ( in_array( $status, [ 'publish', 'future', 'private' ], true ) ) {
1088 if ( $pt_obj && ! current_user_can( $pt_obj->cap->publish_posts ) ) {
1089 return [
1090 'success' => false,
1091 'message' => sprintf( 'You do not have permission to publish %s.', $post->post_type ),
1092 ];
1093 }
1094 }
1095 $update['post_status'] = $status;
1096 $updated[] = 'status';
1097 }
1098
1099 // Content body — inline (content) or pulled from a URL (content_url).
1100 $has_content = array_key_exists( 'content', $input );
1101 $has_content_url = isset( $input['content_url'] ) && trim( (string) $input['content_url'] ) !== '';
1102 if ( $has_content && $has_content_url ) {
1103 return [
1104 'success' => false,
1105 'message' => 'Provide either content or content_url, not both.',
1106 ];
1107 }
1108 if ( $has_content || $has_content_url ) {
1109 $format = isset( $input['content_format'] ) ? (string) $input['content_format'] : 'auto';
1110 if ( $has_content_url ) {
1111 list( $fetched_content, $fetch_err ) = $this->avcf_fetch_content_from_url( (string) $input['content_url'] );
1112 if ( $fetch_err !== null ) {
1113 return [ 'success' => false, 'message' => $fetch_err ];
1114 }
1115 $raw_content = $fetched_content;
1116 } else {
1117 $raw_content = (string) $input['content'];
1118 }
1119 $update['post_content'] = $this->avcf_prepare_content_body( $raw_content, $format );
1120 $updated[] = 'content';
1121 }
1122
1123 if ( array_key_exists( 'excerpt', $input ) ) {
1124 $update['post_excerpt'] = sanitize_textarea_field( (string) $input['excerpt'] );
1125 $updated[] = 'excerpt';
1126 }
1127
1128 if ( array_key_exists( 'date', $input ) && $input['date'] !== '' ) {
1129 list( $local_date, $gmt_date, $date_err ) = $this->avcf_normalize_post_date( (string) $input['date'] );
1130 if ( $date_err !== null ) {
1131 return [ 'success' => false, 'message' => $date_err ];
1132 }
1133 // If status is being set to "future" in this same call, validate date is in the future.
1134 $effective_status = isset( $update['post_status'] ) ? $update['post_status'] : $post->post_status;
1135 if ( $effective_status === 'future' && strtotime( $gmt_date ) <= time() ) {
1136 return [
1137 'success' => false,
1138 'message' => 'status="future" requires a date in the future.',
1139 ];
1140 }
1141 $update['post_date'] = $local_date;
1142 $update['post_date_gmt'] = $gmt_date;
1143 $updated[] = 'date';
1144 }
1145
1146 if ( array_key_exists( 'author', $input ) ) {
1147 $author_id = (int) $input['author'];
1148 if ( $author_id <= 0 ) {
1149 return [ 'success' => false, 'message' => 'author must be a positive user ID.' ];
1150 }
1151 if ( ! get_userdata( $author_id ) ) {
1152 return [ 'success' => false, 'message' => sprintf( 'User %d does not exist.', $author_id ) ];
1153 }
1154 if ( $author_id !== get_current_user_id() ) {
1155 if ( $pt_obj && ! current_user_can( $pt_obj->cap->edit_others_posts ) ) {
1156 return [
1157 'success' => false,
1158 'message' => sprintf( 'You do not have permission to assign %s to another author.', $post->post_type ),
1159 ];
1160 }
1161 }
1162 $update['post_author'] = $author_id;
1163 $updated[] = 'author';
1164 }
1165
1166 if ( array_key_exists( 'parent', $input ) ) {
1167 $parent_id = (int) $input['parent'];
1168 if ( $parent_id < 0 ) {
1169 return [ 'success' => false, 'message' => 'parent must be 0 or a positive post ID.' ];
1170 }
1171 if ( $parent_id > 0 ) {
1172 if ( ! is_post_type_hierarchical( $post->post_type ) ) {
1173 return [
1174 'success' => false,
1175 'message' => sprintf( 'Post type "%s" is not hierarchical; parent must be 0.', $post->post_type ),
1176 ];
1177 }
1178 if ( $parent_id === $id ) {
1179 return [ 'success' => false, 'message' => 'A post cannot be its own parent.' ];
1180 }
1181 $parent_post = get_post( $parent_id );
1182 if ( ! $parent_post || $parent_post->post_type !== $post->post_type ) {
1183 return [
1184 'success' => false,
1185 'message' => sprintf( 'Parent %d does not exist or is not a %s.', $parent_id, $post->post_type ),
1186 ];
1187 }
1188 }
1189 $update['post_parent'] = $parent_id;
1190 $updated[] = 'parent';
1191 }
1192
1193 // Featured media — handled separately (post-insert via set_post_thumbnail).
1194 // 0 = remove the featured image, positive = validate and set.
1195 $featured_media_change = null; // null = unchanged, 0 = remove, >0 = set
1196 if ( array_key_exists( 'featured_media', $input ) ) {
1197 $featured_media_id = (int) $input['featured_media'];
1198 if ( $featured_media_id < 0 ) {
1199 return [ 'success' => false, 'message' => 'featured_media must be 0 or a positive attachment ID.' ];
1200 }
1201 if ( $featured_media_id > 0 ) {
1202 $attach_err = $this->avcf_validate_attachment_id( $featured_media_id );
1203 if ( $attach_err !== null ) {
1204 return [ 'success' => false, 'message' => $attach_err ];
1205 }
1206 }
1207 $featured_media_change = $featured_media_id;
1208 $updated[] = 'featured_media';
1209 }
1210
1211 if ( array_key_exists( 'comment_status', $input ) ) {
1212 $cs = sanitize_key( (string) $input['comment_status'] );
1213 if ( ! in_array( $cs, [ 'open', 'closed' ], true ) ) {
1214 return [ 'success' => false, 'message' => 'comment_status must be "open" or "closed".' ];
1215 }
1216 $update['comment_status'] = $cs;
1217 $updated[] = 'comment_status';
1218 }
1219
1220 if ( array_key_exists( 'ping_status', $input ) ) {
1221 $ps = sanitize_key( (string) $input['ping_status'] );
1222 if ( ! in_array( $ps, [ 'open', 'closed' ], true ) ) {
1223 return [ 'success' => false, 'message' => 'ping_status must be "open" or "closed".' ];
1224 }
1225 $update['ping_status'] = $ps;
1226 $updated[] = 'ping_status';
1227 }
1228
1229 // Nothing actually changed (only ID was passed, and no featured_media change).
1230 if ( count( $update ) === 1 && $featured_media_change === null ) {
1231 return [
1232 'success' => false,
1233 'message' => 'No fields provided to update. Pass at least one field to change.',
1234 ];
1235 }
1236
1237 // Only call wp_update_post if there's something in the post table to update.
1238 if ( count( $update ) > 1 ) {
1239 $result = wp_update_post( $update, true );
1240 if ( is_wp_error( $result ) ) {
1241 return [
1242 'success' => false,
1243 'message' => 'Update failed: ' . $result->get_error_message(),
1244 ];
1245 }
1246 }
1247
1248 // Apply featured image change (separately because wp_update_post does not handle _thumbnail_id).
1249 if ( $featured_media_change !== null ) {
1250 if ( $featured_media_change === 0 ) {
1251 delete_post_thumbnail( $id );
1252 } else {
1253 set_post_thumbnail( $id, $featured_media_change );
1254 }
1255 }
1256
1257 $fresh = get_post( $id );
1258
1259 return [
1260 'success' => true,
1261 'id' => $id,
1262 'title' => $fresh->post_title,
1263 'slug' => $fresh->post_name,
1264 'status' => $fresh->post_status,
1265 'post_type' => $fresh->post_type,
1266 'url' => get_permalink( $id ),
1267 'excerpt' => $fresh->post_excerpt,
1268 'date' => $fresh->post_date,
1269 'author' => (int) $fresh->post_author,
1270 'parent' => (int) $fresh->post_parent,
1271 'featured_media' => (int) get_post_thumbnail_id( $id ),
1272 'comment_status' => $fresh->comment_status,
1273 'ping_status' => $fresh->ping_status,
1274 'updated' => $updated,
1275 'message' => sprintf( 'Updated: %s.', implode( ', ', $updated ) ),
1276 ];
1277 },
1278 'permission_callback' => function() {
1279 return current_user_can( 'edit_posts' );
1280 },
1281 'meta' => [
1282 'mcp' => [ 'public' => true, 'type' => 'tool' ],
1283 'annotations' => [
1284 'readonly' => false,
1285 'destructive' => false,
1286 'idempotent' => true,
1287 ],
1288 ],
1289 ] );
1290
1291 // ---- bulk-update-content ----
1292 wp_register_ability( 'atarim/bulk-update-content', [
1293 'label' => 'Bulk Update Content',
1294 '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.',
1295 'category' => 'atarim',
1296 'input_schema' => [
1297 'type' => 'object',
1298 'properties' => [
1299 'ids' => [
1300 'type' => 'array',
1301 'description' => 'Post IDs to update.',
1302 'items' => [ 'type' => 'integer', 'minimum' => 1 ],
1303 'minItems' => 1,
1304 'maxItems' => 500,
1305 ],
1306 'field' => [
1307 'type' => 'string',
1308 '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.',
1309 'enum' => [ 'status', 'author', 'parent', 'comment_status', 'ping_status' ],
1310 ],
1311 'value' => [
1312 'description' => 'The new value for the chosen field. Type depends on the field: string for status / comment_status / ping_status, integer for author / parent.',
1313 ],
1314 ],
1315 'required' => [ 'ids', 'field', 'value' ],
1316 'additionalProperties' => false,
1317 ],
1318 'output_schema' => [
1319 'type' => 'object',
1320 'properties' => [
1321 'success' => [ 'type' => 'boolean' ],
1322 'attempted' => [ 'type' => 'integer' ],
1323 'updated' => [ 'type' => 'integer' ],
1324 'failed' => [ 'type' => 'integer' ],
1325 'results' => [
1326 'type' => 'array',
1327 'items' => [
1328 'type' => 'object',
1329 'properties' => [
1330 'id' => [ 'type' => 'integer' ],
1331 'success' => [ 'type' => 'boolean' ],
1332 'message' => [ 'type' => 'string' ],
1333 ],
1334 ],
1335 ],
1336 'message' => [ 'type' => 'string' ],
1337 ],
1338 'required' => [ 'success', 'attempted', 'updated', 'failed', 'results', 'message' ],
1339 ],
1340 'execute_callback' => function( $input = [] ) {
1341 $ids = isset( $input['ids'] ) && is_array( $input['ids'] ) ? array_values( array_unique( array_map( 'intval', $input['ids'] ) ) ) : [];
1342 $field = isset( $input['field'] ) ? sanitize_key( $input['field'] ) : '';
1343 $value = $input['value'] ?? null;
1344
1345 if ( empty( $ids ) ) {
1346 return [
1347 'success' => false,
1348 'attempted' => 0,
1349 'updated' => 0,
1350 'failed' => 0,
1351 'results' => [],
1352 'message' => 'ids is required and must be a non-empty array of positive integers.',
1353 ];
1354 }
1355
1356 $allowed_fields = [ 'status', 'author', 'parent', 'comment_status', 'ping_status' ];
1357 if ( ! in_array( $field, $allowed_fields, true ) ) {
1358 return [
1359 'success' => false,
1360 'attempted' => count( $ids ),
1361 'updated' => 0,
1362 'failed' => 0,
1363 'results' => [],
1364 'message' => sprintf( 'field must be one of: %s.', implode( ', ', $allowed_fields ) ),
1365 ];
1366 }
1367
1368 // Validate the value once up-front. Same rules apply to every post.
1369 $normalized_value = null;
1370 $value_error = null;
1371
1372 switch ( $field ) {
1373 case 'status':
1374 $s = is_string( $value ) ? sanitize_key( $value ) : '';
1375 if ( ! in_array( $s, [ 'publish', 'draft', 'pending', 'private', 'future' ], true ) ) {
1376 $value_error = 'value must be one of: publish, draft, pending, private, future.';
1377 }
1378 $normalized_value = $s;
1379 break;
1380 case 'author':
1381 $a = (int) $value;
1382 if ( $a <= 0 ) {
1383 $value_error = 'value must be a positive user ID.';
1384 } elseif ( ! get_userdata( $a ) ) {
1385 $value_error = sprintf( 'User %d does not exist.', $a );
1386 }
1387 $normalized_value = $a;
1388 break;
1389 case 'parent':
1390 $p = (int) $value;
1391 if ( $p < 0 ) {
1392 $value_error = 'value must be 0 or a positive post ID.';
1393 }
1394 $normalized_value = $p;
1395 break;
1396 case 'comment_status':
1397 case 'ping_status':
1398 $s = is_string( $value ) ? sanitize_key( $value ) : '';
1399 if ( ! in_array( $s, [ 'open', 'closed' ], true ) ) {
1400 $value_error = sprintf( 'value must be "open" or "closed" for %s.', $field );
1401 }
1402 $normalized_value = $s;
1403 break;
1404 }
1405
1406 if ( $value_error !== null ) {
1407 return [
1408 'success' => false,
1409 'attempted' => count( $ids ),
1410 'updated' => 0,
1411 'failed' => 0,
1412 'results' => [],
1413 'message' => $value_error,
1414 ];
1415 }
1416
1417 // Per-id processing.
1418 $results = [];
1419 $updated = 0;
1420 $failed = 0;
1421
1422 foreach ( $ids as $id ) {
1423 if ( $id <= 0 ) {
1424 $results[] = [ 'id' => $id, 'success' => false, 'message' => 'Invalid id.' ];
1425 $failed++;
1426 continue;
1427 }
1428
1429 $post = get_post( $id );
1430 if ( ! $post ) {
1431 $results[] = [ 'id' => $id, 'success' => false, 'message' => 'Post not found.' ];
1432 $failed++;
1433 continue;
1434 }
1435
1436 $pt_obj = get_post_type_object( $post->post_type );
1437 if ( $pt_obj && ! current_user_can( $pt_obj->cap->edit_post, $id ) ) {
1438 $results[] = [ 'id' => $id, 'success' => false, 'message' => 'Permission denied.' ];
1439 $failed++;
1440 continue;
1441 }
1442
1443 // Field-specific additional checks.
1444 if ( $field === 'status' && in_array( $normalized_value, [ 'publish', 'private', 'future' ], true ) ) {
1445 if ( $pt_obj && ! current_user_can( $pt_obj->cap->publish_posts ) ) {
1446 $results[] = [ 'id' => $id, 'success' => false, 'message' => 'Permission denied (publish capability required).' ];
1447 $failed++;
1448 continue;
1449 }
1450 }
1451 if ( $field === 'author' && $normalized_value !== (int) $post->post_author ) {
1452 if ( $pt_obj && ! current_user_can( $pt_obj->cap->edit_others_posts ) ) {
1453 $results[] = [ 'id' => $id, 'success' => false, 'message' => 'Permission denied (edit_others_posts capability required).' ];
1454 $failed++;
1455 continue;
1456 }
1457 }
1458 if ( $field === 'parent' && $normalized_value > 0 ) {
1459 if ( ! is_post_type_hierarchical( $post->post_type ) ) {
1460 $results[] = [ 'id' => $id, 'success' => false, 'message' => sprintf( 'Post type "%s" is not hierarchical.', $post->post_type ) ];
1461 $failed++;
1462 continue;
1463 }
1464 if ( $normalized_value === $id ) {
1465 $results[] = [ 'id' => $id, 'success' => false, 'message' => 'A post cannot be its own parent.' ];
1466 $failed++;
1467 continue;
1468 }
1469 }
1470
1471 $update = [ 'ID' => $id ];
1472 switch ( $field ) {
1473 case 'status': $update['post_status'] = $normalized_value; break;
1474 case 'author': $update['post_author'] = $normalized_value; break;
1475 case 'parent': $update['post_parent'] = $normalized_value; break;
1476 case 'comment_status': $update['comment_status'] = $normalized_value; break;
1477 case 'ping_status': $update['ping_status'] = $normalized_value; break;
1478 }
1479
1480 $result = wp_update_post( $update, true );
1481 if ( is_wp_error( $result ) ) {
1482 $results[] = [ 'id' => $id, 'success' => false, 'message' => $result->get_error_message() ];
1483 $failed++;
1484 continue;
1485 }
1486
1487 $results[] = [ 'id' => $id, 'success' => true, 'message' => 'OK.' ];
1488 $updated++;
1489 }
1490
1491 $attempted = count( $ids );
1492
1493 return [
1494 'success' => ( $failed === 0 ),
1495 'attempted' => $attempted,
1496 'updated' => $updated,
1497 'failed' => $failed,
1498 'results' => $results,
1499 'message' => sprintf( '%d of %d updated, %d failed.', $updated, $attempted, $failed ),
1500 ];
1501 },
1502 'permission_callback' => function() {
1503 return current_user_can( 'edit_posts' );
1504 },
1505 'meta' => [
1506 'mcp' => [ 'public' => true, 'type' => 'tool' ],
1507 'annotations' => [
1508 'readonly' => false,
1509 'destructive' => false,
1510 'idempotent' => true,
1511 ],
1512 ],
1513 ] );
1514
1515 // ---- delete-content ----
1516 wp_register_ability( 'atarim/delete-content', [
1517 'label' => 'Delete Content',
1518 'description' => 'Moves a post, page, or custom post type item to trash by default. Pass force: true to permanently delete (skips trash, irreversible).',
1519 'category' => 'atarim',
1520 'input_schema' => [
1521 'type' => 'object',
1522 'properties' => [
1523 'id' => [
1524 'type' => 'integer',
1525 'description' => 'Post ID. Required.',
1526 'minimum' => 1,
1527 ],
1528 'force' => [
1529 'type' => 'boolean',
1530 'description' => 'If true, permanently delete (bypass trash). Defaults to false (move to trash).',
1531 'default' => false,
1532 ],
1533 ],
1534 'required' => [ 'id' ],
1535 'additionalProperties' => false,
1536 ],
1537 'output_schema' => [
1538 'type' => 'object',
1539 'properties' => [
1540 'success' => [ 'type' => 'boolean' ],
1541 'id' => [ 'type' => 'integer' ],
1542 'post_type' => [ 'type' => 'string' ],
1543 'action' => [ 'type' => 'string' ],
1544 'message' => [ 'type' => 'string' ],
1545 ],
1546 'required' => [ 'success', 'message' ],
1547 ],
1548 'execute_callback' => function( $input = [] ) {
1549 $id = isset( $input['id'] ) ? (int) $input['id'] : 0;
1550 if ( $id <= 0 ) {
1551 return [
1552 'success' => false,
1553 'message' => 'id is required and must be a positive integer.',
1554 ];
1555 }
1556
1557 $force = ! empty( $input['force'] );
1558
1559 $post = get_post( $id );
1560 if ( ! $post ) {
1561 return [
1562 'success' => false,
1563 'message' => sprintf( 'Post %d not found.', $id ),
1564 ];
1565 }
1566
1567 // Per-post-type capability check.
1568 $pt_obj = get_post_type_object( $post->post_type );
1569 if ( $pt_obj && ! current_user_can( $pt_obj->cap->delete_post, $id ) ) {
1570 return [
1571 'success' => false,
1572 'message' => sprintf( 'You do not have permission to delete this %s.', $post->post_type ),
1573 ];
1574 }
1575
1576 $post_type = $post->post_type;
1577
1578 if ( $force ) {
1579 $result = wp_delete_post( $id, true );
1580
1581 if ( ! $result ) {
1582 return [
1583 'success' => false,
1584 'id' => $id,
1585 'post_type' => $post_type,
1586 'action' => 'force_delete',
1587 'message' => 'Permanent delete failed.',
1588 ];
1589 }
1590
1591 return [
1592 'success' => true,
1593 'id' => $id,
1594 'post_type' => $post_type,
1595 'action' => 'force_delete',
1596 'message' => 'Post permanently deleted.',
1597 ];
1598 }
1599
1600 // Trash path — wp_trash_post handles post types that support trash;
1601 // for those that don't (e.g. some CPTs registered without trash support),
1602 // it falls back to wp_delete_post internally.
1603 if ( $post->post_status === 'trash' ) {
1604 return [
1605 'success' => false,
1606 'id' => $id,
1607 'post_type' => $post_type,
1608 'action' => 'trash',
1609 'message' => 'Post is already in trash. Use force: true to permanently delete.',
1610 ];
1611 }
1612
1613 $result = wp_trash_post( $id );
1614
1615 if ( ! $result ) {
1616 return [
1617 'success' => false,
1618 'id' => $id,
1619 'post_type' => $post_type,
1620 'action' => 'trash',
1621 'message' => 'Move to trash failed.',
1622 ];
1623 }
1624
1625 return [
1626 'success' => true,
1627 'id' => $id,
1628 'post_type' => $post_type,
1629 'action' => 'trash',
1630 'message' => 'Post moved to trash.',
1631 ];
1632 },
1633 'permission_callback' => function() {
1634 return current_user_can( 'delete_posts' );
1635 },
1636 'meta' => [
1637 'mcp' => [ 'public' => true, 'type' => 'tool' ],
1638 'annotations' => [
1639 'readonly' => false,
1640 'destructive' => true,
1641 'idempotent' => false,
1642 ],
1643 ],
1644 ] );
1645
1646 // ---- list-revisions ----
1647 wp_register_ability( 'atarim/list-revisions', [
1648 'label' => 'List Revisions',
1649 '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).',
1650 'category' => 'atarim',
1651 'input_schema' => [
1652 'type' => 'object',
1653 'properties' => [
1654 'id' => [
1655 'type' => 'integer',
1656 'description' => 'Parent post ID.',
1657 'minimum' => 1,
1658 ],
1659 'limit' => [
1660 'type' => 'integer',
1661 'description' => 'Max revisions to return. -1 for all. Defaults to 20.',
1662 'default' => 20,
1663 'minimum' => -1,
1664 ],
1665 'include_content' => [
1666 'type' => 'boolean',
1667 'description' => 'Include the full content of each revision under "content". Defaults to false; only a short content_preview is returned per revision.',
1668 'default' => false,
1669 ],
1670 ],
1671 'required' => [ 'id' ],
1672 'additionalProperties' => false,
1673 ],
1674 'output_schema' => [
1675 'type' => 'object',
1676 'properties' => [
1677 'success' => [ 'type' => 'boolean' ],
1678 'post_id' => [ 'type' => 'integer' ],
1679 'total' => [ 'type' => 'integer' ],
1680 'returned' => [ 'type' => 'integer' ],
1681 'revisions' => [
1682 'type' => 'array',
1683 'items' => [
1684 'type' => 'object',
1685 'properties' => [
1686 'revision_id' => [ 'type' => 'integer' ],
1687 'date' => [ 'type' => 'string' ],
1688 'author' => [ 'type' => 'integer' ],
1689 'author_name' => [ 'type' => 'string' ],
1690 'title' => [ 'type' => 'string' ],
1691 'excerpt' => [ 'type' => 'string' ],
1692 'content_preview' => [ 'type' => 'string' ],
1693 'content' => [ 'type' => 'string' ],
1694 ],
1695 ],
1696 ],
1697 'message' => [ 'type' => 'string' ],
1698 ],
1699 'required' => [ 'success', 'post_id', 'total', 'returned', 'revisions' ],
1700 ],
1701 'execute_callback' => function( $input = [] ) {
1702 $id = isset( $input['id'] ) ? (int) $input['id'] : 0;
1703 if ( $id <= 0 ) {
1704 return [
1705 'success' => false,
1706 'post_id' => 0,
1707 'total' => 0,
1708 'returned' => 0,
1709 'revisions' => [],
1710 'message' => 'id is required and must be a positive integer.',
1711 ];
1712 }
1713
1714 $post = get_post( $id );
1715 if ( ! $post ) {
1716 return [
1717 'success' => false,
1718 'post_id' => $id,
1719 'total' => 0,
1720 'returned' => 0,
1721 'revisions' => [],
1722 'message' => sprintf( 'Post %d not found.', $id ),
1723 ];
1724 }
1725
1726 $pt_obj = get_post_type_object( $post->post_type );
1727 if ( $pt_obj && ! current_user_can( $pt_obj->cap->edit_post, $id ) ) {
1728 return [
1729 'success' => false,
1730 'post_id' => $id,
1731 'total' => 0,
1732 'returned' => 0,
1733 'revisions' => [],
1734 'message' => sprintf( 'You do not have permission to read revisions for this %s.', $post->post_type ),
1735 ];
1736 }
1737
1738 $limit = isset( $input['limit'] ) ? (int) $input['limit'] : 20;
1739 $include_content = ! empty( $input['include_content'] );
1740
1741 // wp_get_post_revisions returns newest-first by default. Auto-draft revisions
1742 // are included; we filter those out as they're noise for an AI caller.
1743 $args = [];
1744 if ( $limit > 0 ) {
1745 $args['posts_per_page'] = $limit;
1746 }
1747 $all = wp_get_post_revisions( $id, $args );
1748
1749 $revisions = [];
1750 foreach ( $all as $rev ) {
1751 // Skip autosaves — not part of the human-visible revision history.
1752 if ( wp_is_post_autosave( $rev ) ) {
1753 continue;
1754 }
1755
1756 $author_obj = get_userdata( (int) $rev->post_author );
1757 $author_name = $author_obj ? $author_obj->display_name : '';
1758
1759 $stripped = trim( wp_strip_all_tags( strip_shortcodes( $rev->post_content ) ) );
1760 $stripped = preg_replace( '/\s+/', ' ', $stripped );
1761 $content_preview = ( strlen( $stripped ) > 200 )
1762 ? substr( $stripped, 0, 200 ) . ''
1763 : $stripped;
1764
1765 $entry = [
1766 'revision_id' => (int) $rev->ID,
1767 'date' => $rev->post_date_gmt,
1768 'author' => (int) $rev->post_author,
1769 'author_name' => $author_name,
1770 'title' => $rev->post_title,
1771 'excerpt' => $rev->post_excerpt,
1772 'content_preview' => $content_preview,
1773 ];
1774 if ( $include_content ) {
1775 $entry['content'] = $rev->post_content;
1776 }
1777 $revisions[] = $entry;
1778 }
1779
1780 return [
1781 'success' => true,
1782 'post_id' => $id,
1783 'total' => count( $revisions ),
1784 'returned' => count( $revisions ),
1785 'revisions' => $revisions,
1786 'message' => sprintf( '%d revision(s) found for post %d.', count( $revisions ), $id ),
1787 ];
1788 },
1789 'permission_callback' => function() {
1790 return current_user_can( 'edit_posts' );
1791 },
1792 'meta' => [
1793 'mcp' => [ 'public' => true, 'type' => 'tool' ],
1794 'annotations' => [
1795 'readonly' => true,
1796 'destructive' => false,
1797 'idempotent' => true,
1798 ],
1799 ],
1800 ] );
1801
1802 // ---- restore-revision ----
1803 wp_register_ability( 'atarim/restore-revision', [
1804 'label' => 'Restore Revision',
1805 '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.',
1806 'category' => 'atarim',
1807 'input_schema' => [
1808 'type' => 'object',
1809 'properties' => [
1810 'revision_id' => [
1811 'type' => 'integer',
1812 'description' => 'Revision ID to restore. Obtained from list-revisions.',
1813 'minimum' => 1,
1814 ],
1815 ],
1816 'required' => [ 'revision_id' ],
1817 'additionalProperties' => false,
1818 ],
1819 'output_schema' => [
1820 'type' => 'object',
1821 'properties' => [
1822 'success' => [ 'type' => 'boolean' ],
1823 'revision_id' => [ 'type' => 'integer' ],
1824 'post_id' => [ 'type' => 'integer' ],
1825 'title' => [ 'type' => 'string' ],
1826 'restored_from_date' => [ 'type' => 'string' ],
1827 'message' => [ 'type' => 'string' ],
1828 ],
1829 'required' => [ 'success', 'message' ],
1830 ],
1831 'execute_callback' => function( $input = [] ) {
1832 $revision_id = isset( $input['revision_id'] ) ? (int) $input['revision_id'] : 0;
1833 if ( $revision_id <= 0 ) {
1834 return [
1835 'success' => false,
1836 'revision_id' => 0,
1837 'post_id' => 0,
1838 'message' => 'revision_id is required and must be a positive integer.',
1839 ];
1840 }
1841
1842 $revision = wp_get_post_revision( $revision_id );
1843 if ( ! $revision ) {
1844 return [
1845 'success' => false,
1846 'revision_id' => $revision_id,
1847 'post_id' => 0,
1848 'message' => sprintf( 'Revision %d not found.', $revision_id ),
1849 ];
1850 }
1851
1852 $parent_id = (int) $revision->post_parent;
1853 $parent = get_post( $parent_id );
1854 if ( ! $parent ) {
1855 return [
1856 'success' => false,
1857 'revision_id' => $revision_id,
1858 'post_id' => $parent_id,
1859 'message' => sprintf( 'Parent post %d for revision %d no longer exists.', $parent_id, $revision_id ),
1860 ];
1861 }
1862
1863 $pt_obj = get_post_type_object( $parent->post_type );
1864 if ( $pt_obj && ! current_user_can( $pt_obj->cap->edit_post, $parent_id ) ) {
1865 return [
1866 'success' => false,
1867 'revision_id' => $revision_id,
1868 'post_id' => $parent_id,
1869 'message' => sprintf( 'You do not have permission to restore revisions for this %s.', $parent->post_type ),
1870 ];
1871 }
1872
1873 $result = wp_restore_post_revision( $revision_id );
1874
1875 if ( is_wp_error( $result ) ) {
1876 return [
1877 'success' => false,
1878 'revision_id' => $revision_id,
1879 'post_id' => $parent_id,
1880 'message' => 'Restore failed: ' . $result->get_error_message(),
1881 ];
1882 }
1883
1884 if ( $result === null || $result === false ) {
1885 return [
1886 'success' => false,
1887 'revision_id' => $revision_id,
1888 'post_id' => $parent_id,
1889 'message' => 'Restore failed: WordPress reported the operation did not complete.',
1890 ];
1891 }
1892
1893 $restored_post = get_post( $parent_id );
1894
1895 return [
1896 'success' => true,
1897 'revision_id' => $revision_id,
1898 'post_id' => $parent_id,
1899 'title' => $restored_post ? $restored_post->post_title : '',
1900 'restored_from_date' => $revision->post_date_gmt,
1901 'message' => sprintf( 'Post %d restored from revision %d (dated %s).', $parent_id, $revision_id, $revision->post_date_gmt ),
1902 ];
1903 },
1904 'permission_callback' => function() {
1905 return current_user_can( 'edit_posts' );
1906 },
1907 'meta' => [
1908 'mcp' => [ 'public' => true, 'type' => 'tool' ],
1909 'annotations' => [
1910 'readonly' => false,
1911 'destructive' => false,
1912 'idempotent' => true,
1913 ],
1914 ],
1915 ] );
1916
1917 // ---- duplicate-post ----
1918 wp_register_ability( 'atarim/duplicate-post', [
1919 'label' => 'Duplicate Post',
1920 '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.',
1921 'category' => 'atarim',
1922 'input_schema' => [
1923 'type' => 'object',
1924 'properties' => [
1925 'post_id' => [
1926 'type' => 'integer',
1927 'description' => 'ID of the post, page, or custom post type item to duplicate.',
1928 ],
1929 ],
1930 'required' => [ 'post_id' ],
1931 'additionalProperties' => false,
1932 ],
1933 'output_schema' => [
1934 'type' => 'object',
1935 'properties' => [
1936 'success' => [ 'type' => 'boolean' ],
1937 'new_post_id' => [ 'type' => 'integer' ],
1938 'source_post_id' => [ 'type' => 'integer' ],
1939 'new_slug' => [ 'type' => 'string' ],
1940 'status' => [ 'type' => 'string' ],
1941 'title' => [ 'type' => 'string' ],
1942 'edit_link' => [ 'type' => 'string' ],
1943 'preview_link' => [ 'type' => 'string' ],
1944 'message' => [ 'type' => 'string' ],
1945 ],
1946 'required' => [ 'success', 'message' ],
1947 ],
1948 'execute_callback' => function( $input ) {
1949 $source_id = isset( $input['post_id'] ) ? absint( $input['post_id'] ) : 0;
1950 if ( ! $source_id ) {
1951 return [ 'success' => false, 'message' => 'A valid post_id is required.' ];
1952 }
1953
1954 $source = get_post( $source_id );
1955 if ( ! $source ) {
1956 return [ 'success' => false, 'message' => sprintf( 'Post %d not found.', $source_id ) ];
1957 }
1958
1959 $post_type = $source->post_type;
1960 $pt_obj = get_post_type_object( $post_type );
1961 if ( ! $pt_obj ) {
1962 return [ 'success' => false, 'message' => sprintf( 'Unknown post type "%s".', $post_type ) ];
1963 }
1964
1965 // Block internal / non-public post types.
1966 $excluded = [
1967 'attachment', 'revision', 'nav_menu_item', 'custom_css',
1968 'customize_changeset', 'oembed_cache', 'user_request', 'wp_block',
1969 'wp_template', 'wp_template_part', 'wp_global_styles', 'wp_navigation',
1970 ];
1971 if ( in_array( $post_type, $excluded, true ) || empty( $pt_obj->public ) ) {
1972 return [ 'success' => false, 'message' => sprintf( 'Post type "%s" cannot be duplicated (internal or non-public).', $post_type ) ];
1973 }
1974
1975 // Capability: must be able to edit the source and create the target type.
1976 if ( ! current_user_can( $pt_obj->cap->edit_post, $source_id ) ) {
1977 return [ 'success' => false, 'message' => 'You do not have permission to duplicate this post.' ];
1978 }
1979 if ( ! current_user_can( $pt_obj->cap->create_posts ) ) {
1980 return [ 'success' => false, 'message' => sprintf( 'You do not have permission to create %s items.', $post_type ) ];
1981 }
1982
1983 // Build the new title + a unique draft slug (wp_insert_post does not
1984 // uniquify slugs for drafts, so compute it explicitly).
1985 $new_title = ( '' !== $source->post_title ) ? $source->post_title . ' (Copy)' : '(Copy)';
1986 $desired_slug = sanitize_title( $new_title );
1987 $unique_slug = wp_unique_post_slug( $desired_slug, 0, 'draft', $post_type, (int) $source->post_parent );
1988
1989 $acting_user = get_current_user_id();
1990
1991 $postarr = [
1992 'post_title' => $new_title,
1993 'post_name' => $unique_slug,
1994 'post_content' => $source->post_content,
1995 'post_excerpt' => $source->post_excerpt,
1996 'post_status' => 'draft',
1997 'post_type' => $post_type,
1998 'post_author' => $acting_user ? $acting_user : (int) $source->post_author,
1999 'post_parent' => (int) $source->post_parent,
2000 'menu_order' => (int) $source->menu_order,
2001 'comment_status' => $source->comment_status,
2002 'ping_status' => $source->ping_status,
2003 'post_password' => $source->post_password,
2004 ];
2005
2006 $new_id = wp_insert_post( wp_slash( $postarr ), true );
2007 if ( is_wp_error( $new_id ) ) {
2008 return [ 'success' => false, 'message' => 'Duplicate failed: ' . $new_id->get_error_message() ];
2009 }
2010
2011 // Copy taxonomy terms for every taxonomy on this post type
2012 // (includes categories, tags, custom taxonomies and post_format).
2013 foreach ( get_object_taxonomies( $post_type ) as $taxonomy ) {
2014 $term_ids = wp_get_object_terms( $source_id, $taxonomy, [ 'fields' => 'ids' ] );
2015 if ( ! is_wp_error( $term_ids ) && ! empty( $term_ids ) ) {
2016 wp_set_object_terms( $new_id, $term_ids, $taxonomy, false );
2017 }
2018 }
2019
2020 // Copy post meta (multi-value safe). _thumbnail_id is copied here, so the
2021 // featured image is shared. Editing-lock and old-slug keys are skipped.
2022 $skip_meta = [ '_edit_lock', '_edit_last', '_wp_old_slug', '_wp_old_date' ];
2023 $all_meta = get_post_meta( $source_id );
2024 if ( is_array( $all_meta ) ) {
2025 foreach ( $all_meta as $meta_key => $meta_values ) {
2026 if ( in_array( $meta_key, $skip_meta, true ) ) {
2027 continue;
2028 }
2029 foreach ( (array) $meta_values as $meta_value ) {
2030 add_post_meta( $new_id, $meta_key, wp_slash( maybe_unserialize( $meta_value ) ) );
2031 }
2032 }
2033 }
2034
2035 $new_post = get_post( $new_id );
2036 $preview_link = get_preview_post_link( $new_id );
2037
2038 return [
2039 'success' => true,
2040 'new_post_id' => (int) $new_id,
2041 'source_post_id' => $source_id,
2042 'new_slug' => $new_post ? $new_post->post_name : $unique_slug,
2043 'status' => 'draft',
2044 'title' => $new_title,
2045 'edit_link' => admin_url( 'post.php?post=' . (int) $new_id . '&action=edit' ),
2046 'preview_link' => $preview_link ? $preview_link : '',
2047 'message' => sprintf( 'Duplicated post %d as draft %d ("%s").', $source_id, (int) $new_id, $new_title ),
2048 ];
2049 },
2050 'permission_callback' => function() {
2051 return current_user_can( 'edit_posts' );
2052 },
2053 'meta' => [
2054 'mcp' => [ 'public' => true, 'type' => 'tool' ],
2055 'annotations' => [
2056 'readonly' => false,
2057 'destructive' => false,
2058 'idempotent' => false,
2059 ],
2060 ],
2061 ] );
2062 }
2063 }