Make WordPress Core

Opened 8 months ago

Last modified 24 hours ago

#64606 assigned enhancement

Add content management abilities

Reported by: jorgefilipecosta Owned by: jorgefilipecosta
Priority: normal Milestone: 7.2
Component: Abilities API Version:
Severity: normal Keywords: has-patch has-unit-tests
Cc: Focuses:

Description (last modified by jorgefilipecosta)

This tracks the addition of get abilities for post types via the Abilities API, enabling AI agents to retrieve posts and pages. It should introduce a show_in_abilities option on register_post_type() to control which post types expose abilities, enabled by default for post and page initially (then we can expand to menus, media etc).
It should support single-post retrieval by ID and multi-post querying with filters for status, author, etc...

Change History (28)

#1 @jorgefilipecosta
8 months ago

  • Type defect (bug) → enhancement

#2 @gziolo
7 months ago

  • Milestone Awaiting Review → Future Release

#3 @JeffPaul
7 months ago

  • Keywords abilities added

This ticket was mentioned in ​PR #11488 on ​WordPress/wordpress-develop by ​@ishikaatxecurify.


6 months ago
#4

  • Keywords has-patch has-unit-tests added

Added get abilities for post types via the Abilities API, enabling AI agents to retrieve posts and pages through core/post-type/{slug}/get.

Trac ticket: https://core.trac.wordpress.org/ticket/64606

## Use of AI Tools

#5 @desrosj
6 months ago

  • Component AI → Abilities API

Moving tickets related to the Abilities API to a new sub-component.

#6 @audrasjb
5 months ago

Removing trunk version as this is not going to be shipped with WP 7.0 but in the next releases.

#7 @desrosj
5 months ago

  • Keywords abilities removed

Removing abilities and abilities-api custom keywords. This is now indicated by the Abilities API component.

#8 @desrosj
5 months ago

  • Keywords has-patch, has-unit-tests → has-patch has-unit-tests
  • Version trunk

Since this is an enhancement, there's no first version of WordPress this can be reproduced in. Removing trunk version.

#9 @gziolo
5 months ago

  • Milestone Future Release → 7.1
  • Owner set to jorgefilipecosta
  • Status new → assigned

This ticket was mentioned in ​Slack in #core-ai by justlevine. ​View the logs.


4 months ago

#11 @jorgefilipecosta
4 months ago

  • Summary Add get and post/pages abilities → Add content management abilities

#12 @justlevine
4 months ago

To make it easier to find, here's the related AI plugin PR ​https://github.com/WordPress/ai/pull/739

#13 @peterwilsoncc
4 months ago

I missed the creation of this ticket while focusing on RTC during the 7.0 release cycle.

@jorgefilipecosta, @gziolo, @justlevine am I able to get some clarity on which is the canonical proposal for inclusion in WordPress Develop?

I'd rather not review any non-starters as it's not helpful for any of us.

#14 @gziolo
4 months ago

My understanding is as follows:

#15 @jorgefilipecosta
3 months ago

  • Description modified (diff)

This ticket was mentioned in ​PR #10665 on ​WordPress/wordpress-develop by ​@jorgefilipecosta.


3 months ago
#16

Part of: ​https://github.com/WordPress/ai/issues/40 cc: @Jameswlepage
Equivalent core PR of: ​https://github.com/WordPress/gutenberg/pull/74234 (in Gutenberg).
Inspired by the work on ​https://github.com/galatanovidiu/mcp-adapter-implementation-example/tree/experiment/layerd-mcp-tools/includes/Abilities by @galatanovidiu.

Ticket: https://core.trac.wordpress.org/ticket/64606

## Core abilities organization

This PR also proposes a logic for how core abilities are organized. In abilities.php, we have two functions: wp_register_core_abilities and wp_register_ability_category (or in the case of Gutenberg, _gutenberg_register_core_abilities and _gutenberg_register_core_ability_categories). These functions then call ability registration functions that are inside the abilities folder. If the ability is simple, it can be registered in just a single internal function, e.g., _wp_register_site_info_ability; for complex abilities, we can register them in a class (like this post management one).

The abilities can be in both Gutenberg and core. Having them in Gutenberg allows us to use them in the workflows functionality that is being worked on by @senadir and allows us to get some testing before core is released.

Gutenberg unregisters the equivalent core ones, so Gutenberg is the source of truth. The same ability can exist in Gutenberg and core, but Gutenberg takes precedence so we can test changes in Gutenberg before releasing in core (similar to what happens with blocks and other WordPress artifacts).

## Core Post management abilities

This PR adds core post management abilities for the WordPress abilities API: core/create-post, core/get-post, core/find-posts, and core/update-post.

It supports nested query support for meta_query, tax_query, and date_query matching WordPress's native WP_Query structure with AND/OR relations. This allows very complex query operations which the REST API does not allow and may be useful for agents to find information.

It uses the permission callback mechanism and tries to correctly check permissions for all cases. The basic permission checking logic first checks the basic and common use case (user can edit, create, or see a post), then we go into specifics in separate functions that are reused for checking status changes (e.g., publish), author changes, and taxonomy assignment permissions.

The class WP_Posts_Abilities_Gutenberg is organized into 6 main areas:

  • Ability Registration: The main part that does the wp_register_ability calls.
  • Initialization: Initializes shared data between the abilities, e.g., schemas.
  • Output Formatting: Formats the post object (and taxonomies) for output shared between all abilities.
  • Permission Checking: Permission checking logic; some parts are also shared between abilities.
  • Query Processing: Utilities to process queries and map the ability input format to the WP_Query format.
  • Data Processing: Utilities to process input data, sanitize it, map to other formats, etc.

## Missing

The idea of this PR is mainly to get feedback if this is the right direction for the post management abilities. There are some things that are missing:

  • Automated testing: Deeply covering all the abilities. If we agree with this approach, I will add the tests to this PR.
  • Partial Edits: Not all edits are possible, e.g., it is not possible to mark a post as sticky. We need to decide if marking a post as sticky should be done on the update/create post abilities, following the REST API approach, or if we should have a specific ability for that, following the wp_core PHP API approach where we have the wp_update_post function and also stick_post/unstick_post functions. We can decide on what to do regarding the missing edits as follow-ups, as the PR is already too big.
  • Pagination: Pagination was not yet implemented; we need to have a general solution for pagination at the abilities API level.

## Test plan

  • Open /wp-admin/edit.php?post_type=post.
  • Open the browser console the following examples.
const abilitiesAPI = (await import( '@wordpress/abilities' ) );
  • [ ] Verify core/create-post creates posts with various fields (title, content, status, meta, taxonomies):
await wp.apiFetch({path: '/wp-abilities/v1/abilities/core/create-post/run', method: 'POST', data: { input: {post_type: 'post', title: 'Hello World', content: 'My awesome content', 'status': 'publish', meta: {a:23, c:1} } } });
  • [ ] Verify core/get-post retrieves posts by ID with optional taxonomy/meta inclusion:
await wp.apiFetch({path: '/wp-abilities/v1/abilities/core/get-post/run', method: 'POST', data: { input: {id: 334, include_meta: true, include_taxonomies: true} } })
  • [ ] Verify core/find-posts queries work with nested meta_query, tax_query, and date_query.
  • Create posts with the following structure where the title a-23|c-1 represents a post with meta key "a" value of 23 and meta key "c" value of 1. Also adds the correct tags.

https://github.com/user-attachments/assets/a5e8f456-1739-41f2-81ee-040c6c02b527

  • Execute complex find post queries like:
await wp.apiFetch({path: '/wp-abilities/v1/abilities/core/find-posts/run', method: 'POST', data: { input: {

    post_type: 'post',

    include_meta: true,

    meta_query: {

        queries: [

            {

                key: 'footnotes',

                compare: 'EXISTS',

            },

        ]

    }

} } });

await wp.apiFetch({path: '/wp-abilities/v1/abilities/core/find-posts/run', method: 'POST', data: { input: {

    post_type: 'post',

    include_meta: true,

    meta_query: {

        relation: 'AND',

        queries: [

            {

                key: 'a',

                compare: '=',

                value: '23'

            },

            {

                relation: 'OR',

                queries: [

            {

                key: 'b',

                compare: '=',

                value: '1'

            }, {

                key: 'c',

                compare: '=',

                value: '1'

            } ] }



        ]

    }

} } });

await wp.apiFetch({path: '/wp-abilities/v1/abilities/core/find-posts/run', method: 'POST', data: { input: {

    post_type: 'post',

    include_meta: true,

    tax_query: {

        relation: 'AND',

        queries: [

            {

                taxonomy: 'post_tag',

                field: 'slug',

                terms: ['t-23']

            },

            {

                relation: 'OR',

                queries: [

                    {

                        taxonomy: 'post_tag',

                        field: 'slug',

                        terms: ['c-1']

                    },

                    {

                        taxonomy: 'post_tag',

                        field: 'slug',

                        terms: ['b-1']

                    }

                ]

            }

        ]

    }

} } });



await wp.apiFetch({path: '/wp-abilities/v1/abilities/core/find-posts/run', method: 'POST', data: { input: {

    post_type: 'post',

    date_query: {

        relation: 'AND',

        queries: [

            { year: 2025 },

            {

                relation: 'OR',

                queries: [

                    { day: 26 },

                    { month: 11 }

                ]

            }

        ]

    }

} } });
  • [ ] Verify core/update-post modifies existing posts correctly:
await wp.apiFetch({path: '/wp-abilities/v1/abilities/core/update-post/run', method: 'POST', data: { input: {
    id: 327,
    content: 'Hello'
} } });
  • [ ] Test permission checks prevent unauthorized access, e.g., users without publish permissions trying to change status to published, etc.

#17 @gziolo
3 months ago

  • Milestone 7.1 → 7.2

Moved to WordPress 7.2 based on ​the decision made after evaluating feedback on the merge proposal.

This ticket was mentioned in ​PR #12195 on ​WordPress/wordpress-develop by ​@jorgefilipecosta.


3 months ago
#18

## Summary

Part of: ​https://github.com/WordPress/ai/issues/40

Ticket https://core.trac.wordpress.org/ticket/64606

Adds the read-only core/read-content ability to WordPress Core, matching the companion plugin implementation in ​https://github.com/WordPress/ai/pull/739.

A native show_in_abilities post type argument controls exposure and is enabled for post and page. The ability has three explicit modes:

  • single post by id, with an optional post_type guard, returning the post directly
  • single post by post_type and slug, returning the post directly
  • query mode returning { posts, total, total_pages }, with status, author, parent, include, fields, page, and per_page

The lean default fields are id, post_type, status, date, slug, and title_rendered. Heavier fields are opt-in through fields; raw fields require edit access, while rendered fields can be requested for readable posts.

## Security

Uses a collection-level capability gate plus per-post read/edit checks. Missing, unreadable, mismatched, and unexposed posts fail closed. Public but non-viewable statuses require edit access, inherited content follows a readable parent, and password-protected rendered fields are withheld from users who cannot edit the post.

## Query behavior

Query results use normal WP_Query date ordering, newest first. include limits results without preserving caller order. An omitted per_page expands to the included ID count up to 100. Out-of-range pages and invalid or unsupported filters return explicit errors instead of silently widening the query. Totals remain in the response body and can exceed returned rows when row-level permissions withhold content.

## Tests

The PHPUnit ability suite mirrors every portable integration test from the plugin PR, covering registration, schemas, permissions, ID/slug/query modes, fields, pagination, filters, ordering, empty projections, rendering, cache priming, and dates. Core-specific REST dispatch tests cover authentication, permissions, retrieval, include ordering, pagination, and out-of-range errors.

#19 @ugyensupport
8 weeks ago

Test Report: Trac #64606

Add content management abilities

Ticket: https://core.trac.wordpress.org/ticket/64606
Status: assigned / enhancement, milestone 7.2, keywords has-patch has-unit-tests

Which of the three PRs I tested, and why

The ticket lists three PRs. Per maintainer clarification in comment #14
(gziolo):

I tested #12195.

What it does

Adds a single read-only core/read-content ability with three modes:

  • Single post by id (optional post_type guard).
  • Single post by post_type + slug.
  • Query mode: filter by status, author, parent, include; returns { posts, total, total_pages }.

A new show_in_abilities argument on register_post_type() controls
exposure (default false; core sets it true for post and page only).
Lean default fields (id, post_type, status, date, slug,
title_rendered); heavier/raw fields are opt-in via fields and gated by
edit capability. Security model is fail-closed: missing, unreadable,
mismatched, or unexposed posts all reject rather than leak existence.

Note: because this ability is marked readonly in its annotations, the
existing (unrelated to this PR) REST run-controller convention requires
GET, not POST — input travels as a JSON-shaped input query param, not a
JSON body. Worth knowing if you're testing this by hand.

Environment note

The PR branch (add/core-content-ability) is based on a stale trunk
snapshot
— diffing it against current trunk pulls in a lot of unrelated
noise (phpstan annotation reverts, a removed _reset_privacy_policy_page_for_post()
that trunk added later, etc.). That's normal drift for a long-open PR, not a
defect, but it will need a rebase before merge, and I didn't rely on the raw
diff to judge scope — I checked out the PR's own commit and reasoned from
that.

Also had to run composer config audit.block-insecure false before
composer install would resolve — Composer's advisory DB has since flagged
exact-pinned dev-tool versions (squizlabs/php_codesniffer 3.13.5,
wp-coding-standards/wpcs 3.3.0) as insecure. Unrelated to this PR; just an
environment note for next time.

Automated tests (PR's own suites)

[cols="3,1,1"]
|===
|Suite |Tests |Result

|tests/phpunit/tests/abilities-api/wpRegisterCoreContentAbility.php
|90 tests / 317 assertions
|PASS

|tests/phpunit/tests/rest-api/wpRestAbilitiesContentController.php
|11 tests / 32 assertions
|PASS
|===

Regression check — wpAbility.php, wpGetAbilities.php, wpRegisterAbility.php,
wpAbilitiesRegistry.php (73 tests / 114 assertions) and the core
WP_Post_Type unit tests (24 tests / 68 assertions), all pass — the new
show_in_abilities post-type argument doesn't disturb existing post-type
behavior.

php -l on all 4 changed/added core files: clean.

Manual verification (live wp-cli site + real REST dispatch)

  • core/read-content registers; post/page have show_in_abilities = true by default, attachment does not.
  • Logged-out request → 401, denied.
  • Admin, single post by ID → 200, correct post, lean default fields only (title_rendered present, content_raw absent).
  • Admin, single post by post_type + slug → 200, same post.
  • Admin, query mode (post_type=post&status=publish) → 200, {posts, total, total_pages} shape; published post included, a sibling draft correctly excluded by the status filter.
  • Subscriber requesting an edit-context field (content_raw) on a post they can't edit → 403 rest_ability_cannot_execute.
  • Subscriber reading default (lean) fields of a published post → 200 (can read).
  • Subscriber requesting another user's draft by ID → 403, fails closed as documented (no "not found" leak vs. "forbidden" distinction to probe with).
  • Edge cases: nonexistent post ID → 403 (fails closed, doesn't reveal existence); out-of-range page → clean 400 content_invalid_page_number; unsupported/unknown post_type → clean 400 schema-validation error. All match the PR description's stated behavior ("fail closed," "explicit errors instead of silently widening the query").

Conclusion

PR #12195 works as described. Both of its own PHPUnit suites pass (101 tests
/ 349 assertions total), no regressions in the surrounding abilities-api or
post-type code, and independent manual testing over real REST dispatch
confirms every documented behavior: default field leanness, edit-gated raw
fields, fail-closed permission handling on IDs/slugs/query mode, and clean
explicit errors on bad pagination/post-type input. This is the ability the
maintainers intend to land (per comment #14) and it does what it says. Only
non-blocking note is the stale base needing a rebase before merge — nothing
in the ability's own logic needs a fix.

​@gziolo commented on ​PR #12195:


2 weeks ago
#20

In addition to addressing the feedback from @peterwilsoncc, we need to sync the latest fixes applied in the AI plugin. Most importantly:

#21 @gziolo
13 days ago

@jorgefilipecosta, would it make sense to split this ticket? I'm confident we can give a try ship core/content-query in WordPress 7.2 because it's been in the AI plugin for a full release cycle. Write abilities are still in development, so they should target WordPress 7.3.

​@gziolo commented on ​PR #12195:


7 days ago
#22

The previous review feedback is addressed, and local verification passed for the focused tests and the broader Abilities API suite on both single-site and multisite.

Two follow-ups remain before approval:

  • Please remove open_world and its accompanying comment/assertion, since WordPress Core does not support this annotation.
  • The rendering helpers do not fully restore loop globals when there is no surrounding post. To reproduce:
    1. Start with an empty global WP_Query and no $GLOBALS['post'].
    2. Execute core/content-query for a published post with fields: ['content_rendered'].
    3. Inspect $GLOBALS['pages']: it still contains the rendered post’s content.

setup_postdata() populates these globals, but wp_reset_postdata() cannot restore them because the main query has no post. The excerpt helper uses the same cleanup pattern. Please preserve and restore the affected globals, including their previously unset state, and add regression coverage.

For awareness, this PR incorporates the author_slug contract from the still-open ​WordPress/ai#1025. Keep the implementations synchronized as that PR settles.

#23 @jorgefilipecosta
7 days ago

Hi @gziolo I created a separate ticket at https://core.trac.wordpress.org/ticket/66268.

​@jorgefilipecosta commented on ​PR #12195:


6 days ago
#24

Hi @peterwilsoncc, thank you for the reviews, all the feedback was applied 👍

​@jorgefilipecosta commented on ​PR #12195:


5 days ago
#25

LMK if this is no longer the canonical URL as there are a few linked to the ticket.

Hi @peterwilsoncc, yes this is the canonical PR for https://core.trac.wordpress.org/ticket/66268 👍

​@ugyensupport commented on ​PR #12195:


25 hours ago
#26

Thanks for all the iterations here, this is in great shape. A few things I didn't see raised yet:

  1. Top-level oneOf in the input schema. Through the MCP adapter this becomes an LLM tool schema, and some providers (Claude, OpenAI strict mode) reject oneOf/anyOf at the top level. Worth checking against a real client. If it fails, a flat object with the mode checked in code would avoid it.
  2. Registration timing. The type/status lists are fixed when the ability registers, so a post type registered later with show_in_abilities => true is silently left out. A _doing_it_wrong() (or a note in the docs) would help, plus a warning when show_in_abilities isn't a boolean ('1', 1, arrays).
  3. Lean field requests still call setup_postdata() (and fire the_post) for every row. It could be skipped when no rendered field or link is requested.
  4. Small consistency nits: invalid page/per_page silently fall back while other filters fail closed, author_slug returns '' rather than null for a deleted author, and the slug lookup could set orderby/posts_per_page explicitly.

Happy to help test any of these.

​@ugyensupport commented on ​PR #12195:


24 hours ago
#27

Test report

Tested PR head ad971412d on PHP 8.5.7 and MySQL 5.7.

Test suites

Run Result
--group 66268 (single site) ✅ 146 tests, 509 assertions
--group 66268 (multisite) ✅ 148 tests, 516 assertions
--group abilities-api (regression) ✅ 552 tests, 1593 assertions
Post type tests (Tests_Post_Types*) ✅ 48 tests, 149 assertions
PHPCS on changed src/ files ✅ 0 errors (the 22 warnings in post.php are pre-existing, same count on base)

Follow-up on my earlier comment

I checked the points from ​my earlier comment with a few throwaway tests:

Point Observed Status
Top-level oneOf Input and output schemas only have type + oneOf at the top level, no properties. The MCP adapter's SchemaTransformer passes type: object schemas through unchanged, so MCP clients receive {type, oneOf, properties: {}}. Confirmed
oneOf error messages A query for a post type not in the enum fails with: *"input is not a valid Get a single readable post by ID. Reason: id is a required property of input."* The error describes the wrong mode, which is confusing for agents. New finding
Late post type registration A post type registered with show_in_abilities => true after the ability is rejected with ability_invalid_input, and no notice. Confirmed
Non-boolean show_in_abilities 1, '1', 'true', and array( 'read' ) are all stored as false, with no notice. Confirmed
Lean fields still set up posts fields: [ 'id', 'slug' ] for 5 rows fired the_post 5 times. Confirmed
Slug lookup resolved twice Slug mode runs the slug WP_Query twice per call (permission check and execute). Confirmed
Slug query uses defaults posts_per_page and orderby are unset, so the site's posts_per_page option and the default order apply. Confirmed
Deleted author author_slug returns "", while missing dates return null. Confirmed
Invalid page / per_page page: "abc" is rejected by schema validation in WP_Ability::execute(). The fallback is only reachable by calling the callback directly. Not an issue, please disregard

Verdict

Works as described. All tests pass on single site and multisite, with no regressions. The points above are suggestions, not blockers.

​@gziolo commented on ​PR #12195:


24 hours ago
#28

Top-level oneOf in the input schema.

Confirmed. @jorgefilipecosta is working on enhanced schemas that will address tool calling incompatibility with models.

Registration timing

Post types register on init, so this should be good. We can additional check for extenders to report detected incompatibility when a custom post type that enables show_in_abilities registers after abilities_api_init triggered.

Note: See TracTickets for help on using tickets.