> $registry Registry. * @return array> */ function aafm_register_pages_definitions( array $registry ): array { $registry['aafm/get-pages'] = array( 'label' => __( 'Get pages', 'agent-abilities-for-mcp' ), 'description' => __( 'List pages filtered by status and search term. Each item returns id, title, status, type, slug, link, author {id, display_name}, dates, excerpt, terms grouped by taxonomy, featured_image {id, url, alt} or null, and allowlisted meta. Set include_content=true to also return full content per item. Response includes total.', 'agent-abilities-for-mcp' ), 'group' => 'reads', 'risk' => 'read', 'subject' => 'content', 'args_builder' => 'aafm_args_get_pages', ); $registry['aafm/get-page'] = array( 'label' => __( 'Get page', 'agent-abilities-for-mcp' ), 'description' => __( 'Retrieve a single page by ID. Returns id, title, status, type, slug, link, author {id, display_name}, dates, full content (rendered HTML by default, or raw markup via content_format; omitted for password-protected pages), excerpt, terms grouped by taxonomy, featured_image {id, url, alt} or null, and meta (allowlisted scalar values only).', 'agent-abilities-for-mcp' ), 'group' => 'reads', 'risk' => 'read', 'subject' => 'content', 'args_builder' => 'aafm_args_get_page', ); $registry['aafm/create-page'] = array( 'label' => __( 'Create page', 'agent-abilities-for-mcp' ), 'description' => __( 'Create a new page, published by default (requires publish_pages to call this ability at all). An explicit draft, pending, or private status is also accepted. Optional: slug, featured_media (attachment id), terms ({taxonomy: [termId]}, replaces existing terms per taxonomy), and meta ({key: value}, allowlisted keys only). Put any block styling in the block delimiter attributes, not inline style, or the editor marks the content invalid.', 'agent-abilities-for-mcp' ), 'group' => 'writes', 'risk' => 'write', 'subject' => 'content', 'args_builder' => 'aafm_args_create_page', ); $registry['aafm/update-page'] = array( 'label' => __( 'Update page', 'agent-abilities-for-mcp' ), 'description' => sprintf( /* translators: %s: page builder names, for example Elementor and Divi. */ __( 'Update an existing page by ID (publishing is a separate gate). Optional: slug, featured_media (attachment id), terms ({taxonomy: [termId]}, replaces existing terms per taxonomy), and meta ({key: value}, allowlisted keys only). Put any block styling in the block delimiter attributes, not inline style, or the editor marks the content invalid. When the page is owned by a foreign page builder (%s), an edit that sends only page_id and excerpt is accepted and the page\'s stored content is left as it was; any other change is refused, and so is every edit when the plugin cannot tell which builder owns it, since a write here would either have no visible effect or corrupt the builder\'s stored markup.', 'agent-abilities-for-mcp' ), aafm_page_builder_names() ), 'group' => 'writes', 'risk' => 'write', 'subject' => 'content', 'args_builder' => 'aafm_args_update_page', ); $registry['aafm/trash-page'] = array( 'label' => __( 'Trash page', 'agent-abilities-for-mcp' ), 'description' => __( 'Move a page to trash (recoverable, never permanently deleted).', 'agent-abilities-for-mcp' ), 'group' => 'writes', 'risk' => 'destructive', 'subject' => 'content', 'args_builder' => 'aafm_args_trash_page', ); $registry['aafm/delete-page'] = array( 'label' => __( 'Delete page', 'agent-abilities-for-mcp' ), 'description' => __( 'Permanently delete a page, bypassing the Trash. This cannot be undone - use trash-page to remove a page recoverably instead.', 'agent-abilities-for-mcp' ), 'group' => 'writes', 'risk' => 'destructive', 'subject' => 'content', 'args_builder' => 'aafm_args_delete_page', ); return $registry; } /** * Args for aafm/get-pages. * * @return array */ function aafm_args_get_pages(): array { return array( 'label' => aafm_ability_label( 'aafm/get-pages' ), 'description' => aafm_ability_description( 'aafm/get-pages' ), 'category' => 'aafm-reads', 'input_schema' => array( 'type' => 'object', 'properties' => array_merge( array( 'status' => array( 'type' => 'string', 'default' => 'publish', 'description' => __( 'Post status to filter by. Defaults to publish. A non-public status (draft, pending, future, private) is only returned when the caller can read private pages; any, trash, auto-draft, inherit, and unrecognized values are rejected.', 'agent-abilities-for-mcp' ), ), 'search' => array( 'type' => 'string', 'description' => __( 'Free-text search term matched against the page title and content, using WordPress\'s normal search matching.', 'agent-abilities-for-mcp' ), ), ), aafm_pagination_schema_props( 50, __( 'Number of items per page, clamped to the 1-50 range regardless of the value requested. Defaults to 10 when omitted.', 'agent-abilities-for-mcp' ), __( '1-based page number for pagination. Defaults to 1.', 'agent-abilities-for-mcp' ) ), array( 'content_format' => array( 'type' => 'string', 'enum' => array( 'rendered', 'raw' ), 'default' => 'rendered', 'description' => __( 'Format for each item\'s content when include_content is true: rendered HTML or raw block markup. Has no effect when include_content is false.', 'agent-abilities-for-mcp' ), ), 'include_content' => array( 'type' => 'boolean', 'default' => false, 'description' => __( 'When true, also returns each item\'s full content (in content_format). Defaults to false, returning metadata only, to keep responses small.', 'agent-abilities-for-mcp' ), ), ), aafm_lang_schema_fragment() ), 'additionalProperties' => false, ), 'output_schema' => array( 'type' => 'object', 'properties' => array( 'posts' => array( 'type' => 'array', 'items' => array( 'type' => 'object', 'properties' => aafm_rich_post_output_properties(), ), ), 'total' => array( 'type' => 'integer' ), 'language' => array( 'type' => array( 'string', 'null' ), 'description' => __( 'The WPML language the list was scoped to ("all" for every language), or null when WPML is inactive.', 'agent-abilities-for-mcp' ), ), ), ), 'execute_callback' => 'aafm_exec_get_pages', 'permission_callback' => 'aafm_perm_read', 'meta' => array( 'annotations' => array( 'readonly' => true, 'destructive' => false, 'idempotent' => true, ), ), ); } /** * Execute aafm/get-pages - delegates to the post query with post_type forced to page. * * @param array $input Validated input. * @return array|WP_Error */ function aafm_exec_get_pages( array $input ) { $input['post_type'] = 'page'; return aafm_exec_get_posts( $input ); } /** * Args for aafm/get-page. * * @return array */ function aafm_args_get_page(): array { return array( 'label' => aafm_ability_label( 'aafm/get-page' ), 'description' => aafm_ability_description( 'aafm/get-page' ), 'category' => 'aafm-reads', 'input_schema' => array( 'type' => 'object', 'properties' => array_merge( array( 'page_id' => array( 'type' => 'integer', 'minimum' => 1, 'description' => __( 'ID of the page to retrieve.', 'agent-abilities-for-mcp' ), ), 'content_format' => array( 'type' => 'string', 'enum' => array( 'rendered', 'raw' ), 'default' => 'rendered', 'description' => __( 'Format for the returned content: rendered HTML (default) or raw block markup.', 'agent-abilities-for-mcp' ), ), 'include_content' => array( 'type' => 'boolean', 'default' => true, 'description' => __( 'Whether to include the page content. Defaults to true; pass false to omit it, for example when only content_length is needed to preflight size.', 'agent-abilities-for-mcp' ), ), ), aafm_lang_schema_fragment() ), 'required' => array( 'page_id' ), 'additionalProperties' => false, ), 'output_schema' => array( 'type' => 'object', 'properties' => array( 'post' => array( 'type' => 'object', 'properties' => aafm_rich_post_output_properties(), ), ), ), 'execute_callback' => 'aafm_exec_get_page', 'permission_callback' => 'aafm_perm_get_page', 'meta' => array( 'annotations' => array( 'readonly' => true, 'destructive' => false, 'idempotent' => true, ), ), ); } /** * Permission for aafm/get-page: read, plus per-object edit for non-public pages. * * When a `lang` is requested, the id is resolved to that language's translation * FIRST, so the capability check below runs against the id that will actually be * loaded - a caller cannot pass a readable id but have a different, unreadable * translation served. * * @param array $input Input. * @return bool */ function aafm_perm_get_page( array $input ): bool { if ( ! current_user_can( 'read' ) ) { return false; } $id = isset( $input['page_id'] ) ? absint( $input['page_id'] ) : 0; if ( $id ) { $id = aafm_get_page_lang_resolved_id( $id, $input ); } $post = $id ? aafm_exact_object( 'post', $id ) : null; // Keep the type pin so a non-page id is still rejected, then delegate to the shared gate. if ( ! $post instanceof WP_Post || 'page' !== $post->post_type ) { return false; } return aafm_can_read_post_object( $post ); } /** * Resolve a requested page id to its WPML translation per the caller's `lang` * input. Feature-detected no-op: returns $id unchanged when WPML is off, no * `lang` was requested, or `lang` is 'all' (no single translation to resolve to). * * @param int $id Original page id. * @param array $input Ability input (read for `lang`). * @return int */ function aafm_get_page_lang_resolved_id( int $id, array $input ): int { $lang = aafm_resolve_lang( $input ); // A WP_Error (invalid code) resolves nothing here; the executor refuses it // before serving data, and the permission callback just checks the original id. if ( ! is_string( $lang ) || 'all' === $lang ) { return $id; } return aafm_wpml_translated_id( $id, 'page', $lang ); } /** * Execute aafm/get-page. * * @param array $input Input. * @return array|WP_Error */ function aafm_exec_get_page( array $input ) { $lang = aafm_resolve_lang( $input ); if ( is_wp_error( $lang ) ) { return $lang; } $id = aafm_get_page_lang_resolved_id( absint( $input['page_id'] ), $input ); $post = aafm_exact_object( 'post', $id ); if ( ! $post instanceof WP_Post || 'page' !== $post->post_type || ! aafm_can_read_post_object( $post ) ) { return aafm_generic_error(); } $format = isset( $input['content_format'] ) ? (string) $input['content_format'] : 'rendered'; $include_content = ! array_key_exists( 'include_content', $input ) || (bool) $input['include_content']; // Branch review fix (lang scope and result shaping): same reasoning as // aafm_exec_get_post() in posts.php - see that function's comment for the full // explanation. Shape under the requested language when one was resolved, or the post's // own language for "all"/no lang, never ambient. $shape_lang = ( is_string( $lang ) && 'all' !== $lang ) ? $lang : aafm_wpml_post_language( $post->ID ); return array( 'post' => aafm_with_language( $shape_lang, static fn(): array => aafm_rich_post( $post, array( 'content_format' => $format, 'include_content' => $include_content, ) ) ), ); } /** * Args for aafm/create-page. * * Reuses the shared closed write schema (additionalProperties:false) from * posts.php, so post_author / post_type / meta_input cannot be smuggled in. * * @return array */ function aafm_args_create_page(): array { return array( 'label' => aafm_ability_label( 'aafm/create-page' ), 'description' => aafm_ability_description( 'aafm/create-page' ), 'category' => 'aafm-writes', 'input_schema' => aafm_write_content_schema( true ), 'output_schema' => array( 'type' => 'object', 'properties' => array( 'post' => array( 'type' => 'object' ), 'enrichment' => aafm_write_enrichment_output_schema(), ), ), 'execute_callback' => 'aafm_exec_create_page', 'permission_callback' => 'aafm_perm_publish_pages', 'meta' => array( 'annotations' => array( 'readonly' => false, 'destructive' => false, ), ), ); } /** * Permission: publish_pages (publish is a separate gate from edit). * * @return bool */ function aafm_perm_publish_pages(): bool { return current_user_can( 'publish_pages' ); } /** * Execute aafm/create-page - type pinned to 'page', defaults to 'publish' (the * permission_callback already requires publish_pages to call this ability at all), honours a * requested draft/pending/private status. * * Delegates to the shared aafm_insert_post(), which never threads post_author * (so authorship is forced to the agent user) and sanitizes title/content. * * @param array $input Input. * @return array|WP_Error */ function aafm_exec_create_page( array $input ) { return aafm_insert_post( $input, 'publish', 'page', 'publish_pages' ); } /** * Args for aafm/update-page. * * @return array */ function aafm_args_update_page(): array { $schema = aafm_write_content_schema( false ); $schema['properties']['page_id'] = array( 'type' => 'integer', 'minimum' => 1, 'description' => __( 'ID of the page to update.', 'agent-abilities-for-mcp' ), ); $schema['required'] = array( 'page_id' ); return array( 'label' => aafm_ability_label( 'aafm/update-page' ), 'description' => aafm_ability_description( 'aafm/update-page' ), 'category' => 'aafm-writes', 'input_schema' => $schema, 'output_schema' => array( 'type' => 'object', 'properties' => array( 'post' => array( 'type' => 'object' ), 'enrichment' => aafm_write_enrichment_output_schema(), ), ), 'execute_callback' => 'aafm_exec_update_page', 'permission_callback' => 'aafm_perm_update_page', 'meta' => array( 'annotations' => array( 'readonly' => false, 'destructive' => false, ), ), ); } /** * Permission for aafm/update-page: per-object edit_page, plus publish_pages when publishing. * * @param array $input Input. * @return bool */ function aafm_perm_update_page( array $input ): bool { $id = isset( $input['page_id'] ) ? absint( $input['page_id'] ) : 0; $post = $id ? aafm_exact_object( 'post', $id ) : null; // Keep the type pin so a non-page id is rejected, then gate the edit through the // shared chokepoint (floor + allowlist + map_meta_cap; resolves to edit_page for pages). if ( ! $post instanceof WP_Post || 'page' !== $post->post_type || ! aafm_can_edit_post_object( $post ) ) { return false; } if ( isset( $input['status'] ) && aafm_status_requires_publish_cap( (string) $input['status'] ) ) { // Mirror aafm_perm_update_post: any public status (not just literal 'publish', so // custom public statuses are covered too) requires the type's publish cap, which // resolves to publish_pages for pages. $caps = aafm_type_caps( $post->post_type ); return $caps['object'] instanceof WP_Post_Type && current_user_can( (string) $caps['object']->cap->publish_posts ); } return true; } /** * Execute aafm/update-page - pins to the page type, then reuses the post updater. * * The id must resolve to an existing page; a non-page id is rejected so the * ability can never be used to edit a post (or any other type) by ID confusion. * * @param array $input Input. * @return array|WP_Error */ function aafm_exec_update_page( array $input ) { $id = absint( $input['page_id'] ); $post = aafm_exact_object( 'post', $id ); if ( ! $post instanceof WP_Post || 'page' !== $post->post_type ) { return aafm_generic_error(); } $input['post_id'] = $id; return aafm_exec_update_post( $input ); } /** * Args for aafm/trash-page. * * @return array */ function aafm_args_trash_page(): array { return array( 'label' => aafm_ability_label( 'aafm/trash-page' ), 'description' => aafm_ability_description( 'aafm/trash-page' ), 'category' => 'aafm-writes', 'input_schema' => array( 'type' => 'object', 'properties' => array( 'page_id' => array( 'type' => 'integer', 'minimum' => 1, 'description' => __( 'ID of the page to move to trash.', 'agent-abilities-for-mcp' ), ), ), 'required' => array( 'page_id' ), 'additionalProperties' => false, ), 'output_schema' => array( 'type' => 'object', 'properties' => array( 'trashed' => array( 'type' => 'boolean' ) ), ), 'execute_callback' => 'aafm_exec_trash_page', 'permission_callback' => 'aafm_perm_trash_page', 'meta' => array( 'annotations' => array( 'readonly' => false, 'destructive' => true, ), ), ); } /** * Permission for aafm/trash-page: per-object delete_page. * * @param array $input Input. * @return bool */ function aafm_perm_trash_page( array $input ): bool { $id = isset( $input['page_id'] ) ? absint( $input['page_id'] ) : 0; $post = $id ? aafm_exact_object( 'post', $id ) : null; // Keep the type pin so a non-page id is rejected, then delegate to the shared delete gate. if ( ! $post instanceof WP_Post || 'page' !== $post->post_type ) { return false; } return aafm_can_delete_post_object( $post ); } /** * Execute aafm/trash-page - wp_trash_post only (recoverable), never wp_delete_post. * * The id must resolve to an existing page; a non-page id is rejected so the * ability can never trash a post by ID confusion. * * @param array $input Input. * @return array|WP_Error */ function aafm_exec_trash_page( array $input ) { if ( ! aafm_trash_is_enabled() ) { return aafm_trash_disabled_error(); } $id = absint( $input['page_id'] ); $post = aafm_exact_object_chain( 'post', $id ); if ( ! $post instanceof WP_Post || 'page' !== $post->post_type ) { return aafm_generic_error(); } if ( ! wp_trash_post( $id ) ) { return aafm_generic_error(); } return array( 'trashed' => true ); } /** * Args for aafm/delete-page. * * @return array */ function aafm_args_delete_page(): array { return array( 'label' => aafm_ability_label( 'aafm/delete-page' ), 'description' => aafm_ability_description( 'aafm/delete-page' ), 'category' => 'aafm-writes', 'input_schema' => array( 'type' => 'object', 'properties' => array( 'page_id' => array( 'type' => 'integer', 'minimum' => 1, 'description' => __( 'ID of the page to permanently delete. This bypasses the Trash; there is no undo.', 'agent-abilities-for-mcp' ), ), ), 'required' => array( 'page_id' ), 'additionalProperties' => false, ), 'output_schema' => array( 'type' => 'object', 'properties' => array( 'deleted' => array( 'type' => 'boolean' ) ), ), 'execute_callback' => 'aafm_exec_delete_page', 'permission_callback' => 'aafm_perm_delete_page', 'meta' => array( 'annotations' => array( 'readonly' => false, 'destructive' => true, ), ), ); } /** * Permission for aafm/delete-page: type-pin to a page, then per-object delete_page. * * @param array $input Input. * @return bool */ function aafm_perm_delete_page( array $input ): bool { $id = isset( $input['page_id'] ) ? absint( $input['page_id'] ) : 0; $post = $id ? aafm_exact_object( 'post', $id ) : null; // Keep the type pin so a non-page id is rejected, then delegate to the shared delete gate. if ( ! $post instanceof WP_Post || 'page' !== $post->post_type ) { return false; } return aafm_can_delete_post_object( $post ); } /** * Execute aafm/delete-page - delegates to the single sanctioned force-delete executor * with the page type pinned, so pages.php never calls wp_delete_post directly (one call * site, in posts.php). * * @param array $input Input. * @return array|WP_Error */ function aafm_exec_delete_page( array $input ) { return aafm_force_delete_post( absint( $input['page_id'] ?? 0 ), 'page' ); }