Skip to content

Add core/content ability#739

Open
jorgefilipecosta wants to merge 23 commits into
developfrom
add/core-content-ability
Open

Add core/content ability#739
jorgefilipecosta wants to merge 23 commits into
developfrom
add/core-content-ability

Conversation

@jorgefilipecosta

@jorgefilipecosta jorgefilipecosta commented Jun 16, 2026

Copy link
Copy Markdown
Member

Summary

Part of: #40

Adds the read-only core/content ability to the plugin, mirroring the WordPress core WP_Content_Abilities implementation (see the companion core PR) so the two stay in sync. It overrides any core-provided copy.

Retrieves one or more posts of a post type exposed to abilities: fetch a single post by id or slug, or query multiple posts filtered by post_type, status, author, parent, with a fields selector and page/per_page pagination. Output is { posts, total, total_pages }.

core PR WordPress/wordpress-develop#12195.

Security

Defense in depth: a coarse status/capability gate plus an authoritative per-post read_post check on every row; default status publish; password-protected content withheld from non-editors; uniform not-found responses to avoid leaking post existence.

Show_In_Abilities

WordPress core does not yet ship the show_in_abilities flag this ability reads, so a self-contained Show_In_Abilities component polyfills it onto curated core post types (post, page). It is structured exactly like the settings polyfill from the core/settings PR (#691), so the two merge cleanly.

This PR is independent of #691 and can be reviewed/merged on its own.

Tests

PHPUnit integration tests for the ability and the polyfill, plus an e2e spec (tests/e2e/specs/abilities/core-content.spec.js) backed by a sample-content plugin that registers an ability-exposed post type.

Open WordPress Playground Preview

@github-actions

github-actions Bot commented Jun 16, 2026

Copy link
Copy Markdown

✅ WordPress Plugin Check Report

✅ Status: Passed

📊 Report

All checks passed! No errors or warnings found.


🤖 Generated by WordPress Plugin Check Action • Learn more about Plugin Check

@jorgefilipecosta jorgefilipecosta changed the title [in progress] Add a core/content ability Add core/content ability Jun 18, 2026
@jorgefilipecosta jorgefilipecosta marked this pull request as ready for review June 18, 2026 09:25
@github-actions

github-actions Bot commented Jun 18, 2026

Copy link
Copy Markdown

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Co-authored-by: jorgefilipecosta <jorgefilipecosta@git.wordpress.org>
Co-authored-by: gziolo <gziolo@git.wordpress.org>
Co-authored-by: peterwilsoncc <peterwilsoncc@git.wordpress.org>
Co-authored-by: galatanovidiu <ovidiu-galatan@git.wordpress.org>
Co-authored-by: justlevine <justlevine@git.wordpress.org>
Co-authored-by: jeffpaul <jeffpaul@git.wordpress.org>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

@jorgefilipecosta jorgefilipecosta force-pushed the add/core-content-ability branch 2 times, most recently from d1db766 to 88df83c Compare June 18, 2026 10:12
Comment thread includes/Main.php Outdated
// Expose curated core objects to the Abilities API, then register the
// `core/settings` and `core/content` abilities (overriding any core-provided copies).
Show_In_Abilities::register();
Settings_Ability::init();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some changes leaked from another PR with the core/settings ability.

@gziolo

gziolo commented Jun 18, 2026

Copy link
Copy Markdown
Member

Nice work so far! A few things to iron out:

  • It looks like some files from the core/settings work (Add a core/settings ability #691) slipped into this branch: Settings.php, SettingsTest.php, the core-settings.spec.js e2e, and the e2e-sample-settings plugin. Since this PR is meant to stand on its own, those should come out (worth double-checking the .gitignore additions too).
  • Input schema needs more thought. The options are really mutually exclusive, but the schema doesn't say so. If you pass id, none of the other params (status, author, parent, page, per_page) fit at all — they're just silently ignored. Same story with slug. The one combination that does make sense is slug together with post_type. So this should be modeled as distinct modes rather than a single flat object where anything goes.
  • I see there is ai/get-post-details in the repo doing basically a subset of this. This work should supersede it, so let's plan to drop the old one rather than ship two overlapping "read a post" abilities.

@gziolo

gziolo commented Jun 23, 2026

Copy link
Copy Markdown
Member

For reference, I'm sharing WP CLI commands related to read-only operations on posts:

  • wp post get – post can be found only by post ID, all fields are returned by default, it's possible to filter fields with --fields or --field
  • wp post list – fields returned by default (ID, post_title, post_name, post_date, post_status), it's possible to filter fields with --fields or --field, --<field>=<value> allows to pass filter results with one or more args supported by WP_Query.

@jeffpaul jeffpaul added this to the 1.1.0 milestone Jun 23, 2026
@jeffpaul jeffpaul moved this from Triage to In progress in WordPress AI Roadmap Jun 23, 2026
@jeffpaul

Copy link
Copy Markdown
Member

@jorgefilipecosta looks like some code review feedback and merge conflicts to clean up to help move this along towards merge and inclusion in the next AI plugin release (to help get some usage testing & feedback before a parallel PR is landed for core in 7.1)

The .mcp.json file holds developer-local MCP server config (xdebug,
chrome-devtools) and should not be in version control. Remove it from
the repo and add it to .gitignore; the file is kept locally.
The e2e global setup deletes all `post` entries, so querying
`post_type: 'post'` for the field-limiting test returned zero posts in
clean CI and failed the `posts.length > 0` assertion. Seed published
posts via `requestUtils.createPost` in a `beforeAll` (tracking their IDs)
and remove only those in `afterAll`, then query `post` as before.
Re-applied on top of the current branch tip (a prior push of these changes was
overwritten by a force-push). Mirrors the core/settings review fixes that apply here:
- Memoize the exposed post types so the input schema and the permission/execute
  callbacks derive from a single walk of the registered post types.
- Default the input schema to an empty object so the type:object default serializes as {}.
- Use __() instead of esc_html__(), and @SInCE x.x.x per CONTRIBUTING.md.
- Harden input/value handling (type guards, capability resolver, non-negative int
  helper, dynamic-property annotation) so the new code passes PHPStan at level max.

The `content` ability-category fallback is kept on purpose: unlike `site`, `content`
is a new category not present on the plugin's minimum WordPress (7.0).
The settings PR consolidated the e2e support plugins into a single
`tests/e2e-testing` plugin. Mirror that here: register the sample
`ai_e2e_sample` post type and seed its published post inside
e2e-testing.php (next to the sample setting), drop the standalone
e2e-sample-content plugin and its .wp-env.test.json entry, and point the
core/content spec comment at e2e-testing.
Mirror the merged core/settings ability (and core's WP_Settings_Abilities):
convert Content from a static class to a final, instance-based one. The
externally-invoked entry points (init, register, register_category,
execute_get_content, check_permission, return_raw_title_format) stay public;
register_get_content() and the shared helpers become private; CATEGORY and the
per-page bounds become private consts; the $fields list and the cached
$exposed_post_types become instance state. Behaviour is unchanged.

Note: core's WP_Content_Abilities is still static; this is the one structural
divergence from the core twin (to be reconciled when core content migrates).
…ities

Rebase off the merged settings PR left Show_In_Abilities as the old static,
settings-only class. Restore develop's final, instance-based version and add the
content polyfill on top as instance methods: mark_post_type() on the
register_post_type_args filter, mark_registered_post_types() to patch core post
types already registered during bootstrap, and a private post_types_map()
(post, page). settings_map() and post_types_map() are both private, matching the
scoped settings class.
@justlevine

justlevine commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

(to help get some usage testing & feedback before a parallel PR is landed for core in 7.1)

This is an unrealistic and IMO unwanted end-goal. I think we need to accept that no new core abilities will ship in 7.1, even if they make it into AI@1.1.0

< 3 weeks is not enough time to get actual API design feedback for core. It's not even a full AI Plugin release cycle.

By all means if we can get this cleaned up in time for the next plugin release, great, but considering that they're not even behind an Experiment toggle, I wouldn't want y'all rushing it in before you or @dkotter think its viable because of a desire to squeeze it into beta1.

cc @gziolo @jmarx

@jorgefilipecosta jorgefilipecosta force-pushed the add/core-content-ability branch from 88df83c to 3a7df4a Compare June 23, 2026 15:00
Drive the Content and Show_In_Abilities tests through instances (held on the
test case so the same instance detaches its hooks on tear down) instead of the
old static calls. Bring ContentTest in line with core's wpRegisterCoreContentAbility:
assert show_in_rest + destructive/idempotent annotations on registration, assert
the input schema type and additionalProperties, add output-schema coverage, and
add a test that a post type registered by another active plugin (flagged
show_in_abilities) is exposed in the input enum and in query results.
@jorgefilipecosta jorgefilipecosta force-pushed the add/core-content-ability branch from 3a7df4a to b3748e6 Compare June 23, 2026 15:22
@codecov

codecov Bot commented Jun 23, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.33333% with 31 lines in your changes missing coverage. Please review.
✅ Project coverage is 77.45%. Comparing base (26ce3f0) to head (b1bb4ac).
⚠️ Report is 5 commits behind head on develop.

Files with missing lines Patch % Lines
includes/Abilities/Content/Content.php 93.06% 31 Missing ⚠️
Additional details and impacted files
@@              Coverage Diff              @@
##             develop     #739      +/-   ##
=============================================
+ Coverage      76.41%   77.45%   +1.04%     
- Complexity      1828     1970     +142     
=============================================
  Files             87       88       +1     
  Lines           7764     8229     +465     
=============================================
+ Hits            5933     6374     +441     
- Misses          1831     1855      +24     
Flag Coverage Δ
unit 77.45% <93.33%> (+1.04%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Per review, the params are not interchangeable: fetching a single post by `id`
and querying a set by `post_type` are distinct modes, and query-only filters
(`status`, `author`, `parent`, `slug`, `page`, `per_page`) make no sense
alongside `id`. The previous flat schema (an `anyOf` requiring `id` or
`post_type`) accepted those combinations and silently ignored the extras.

Model the schema as a `oneOf` of two modes, each with `additionalProperties:
false`, so e.g. `{ id, per_page }` fails validation instead of dropping
`per_page`. `fields` is accepted in both modes; `post_type` stays allowed
alongside `id` as a guard. Tests cover the rejected and accepted combinations.
Remove the `.mcp.json` and `/CLAUDE.local.md` ignore rules that slipped in from
local dev setup; they are unrelated to the core/content ability.
@jorgefilipecosta

Copy link
Copy Markdown
Member Author

Hi @gziolo, your feedback was applied.
Regarding "I see there is ai/get-post-details in the repo doing basically a subset of this. This work should supersede it, so let's plan to drop the old one rather than ship two overlapping "read a post" abilities." I plan to do that as a follow up in order to keep the scope of this PR smaller.

@peterwilsoncc peterwilsoncc left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added a few notes inline.

To avoid burying the lead: I think the key question I have is why you don't include the rendered content so bots can be set up with a read-only account?

Comment thread includes/Abilities/Content/Content.php Outdated
Comment on lines +237 to +239
$edit_posts_capability = $this->capability( $post_type_object, 'edit_posts', 'edit_posts' );

return current_user_can( $edit_posts_capability ); // phpcs:ignore WordPress.WP.Capabilities.Undetermined -- Capability is resolved from the post type's capability object.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_post_type_capabilities() ensures the caps are populated so you can avoid the conditional.

Suggested change
$edit_posts_capability = $this->capability( $post_type_object, 'edit_posts', 'edit_posts' );
return current_user_can( $edit_posts_capability ); // phpcs:ignore WordPress.WP.Capabilities.Undetermined -- Capability is resolved from the post type's capability object.
return current_user_can( $post_type_object->cap->edit_posts );

Comment thread includes/Abilities/Content/Content.php Outdated
* @param string $fallback Fallback capability name if unset or non-string.
* @return string The resolved capability name.
*/
private function capability( \WP_Post_Type $post_type_object, string $name, string $fallback ): string {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can be removed per above.

Comment thread includes/Abilities/Content/Content.php Outdated
Comment on lines +376 to +379
foreach ( get_post_types( array(), 'objects' ) as $post_type_object ) {
if ( empty( $post_type_object->show_in_abilities ) ) {
continue;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should be able to use the core api to determine whether the post type is shown in the abilities.

Suggested change
foreach ( get_post_types( array(), 'objects' ) as $post_type_object ) {
if ( empty( $post_type_object->show_in_abilities ) ) {
continue;
}
foreach ( get_post_types( array( 'show_in_abilities' => true ), 'objects' ) as $post_type_object ) {

Comment thread includes/Abilities/Content/Content.php Outdated
* @return array<string, \WP_Post_Type> Exposed post type objects keyed by name.
*/
private function get_exposed_post_types(): array {
$exposed = array();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For clarity: requires changes elsewhere.

Suggested change
$exposed = array();
$exposed_post_types = array();

Comment thread includes/Abilities/Content/Content.php Outdated
return $this->fields;
}

$requested = array_filter( $input['fields'], 'is_string' );

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clarity

Suggested change
$requested = array_filter( $input['fields'], 'is_string' );
$requested_fields = array_filter( $input['fields'], 'is_string' );

*
* @since x.x.x
*/
public function test_password_protected_content_visible_to_editor(): void {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, needs unhappy path for subs, authors, contribs.

'paged' => $page,
'perm' => 'editable',
'ignore_sticky_posts' => true,
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As you are not including terms or meta currently, I'd bypass priming the caches for now.

Comment thread includes/Abilities/Content/Content.php Outdated
*
* @return string[] List of public, non-internal post status slugs.
*/
private function get_available_statuses(): array {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not needed: used once only.

Comment on lines +150 to +154
private function login_as( string $role ): int {
$user_id = self::factory()->user->create( array( 'role' => $role ) );
wp_set_current_user( $user_id );
return $user_id;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To speed up the tests, add a user for each role type as a shared fixture in wpSetupBeforeClass rather than create a new user account each time.

Same can be done for a few posts, I suspect.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For tests with multiple assertions, add a message to each per unit test standads.

@gziolo

gziolo commented Jun 24, 2026

Copy link
Copy Markdown
Member

After a closer inspection, I want to echo the point that @peterwilsoncc raised regarding the data formatting. We need to decide whether we return raw data or rendered/filtered data. This is what I see now:

  • Dates are in GMT format.
  • Title is currently filtered.
  • Content is in raw format.

What is needed will largely depend on the consumer. If they only want to present the data, then the preferred option would be using dates in the site's timezone, title, and content as rendered HTML. If they want to edit content, then maybe fetching raw data would make more sense. There are two ways to go about it:

  • Make room for both values as suggested in review.
  • Add a high-level flag that controls whether fields contain raw or post-processed fields.

@justlevine, thank you for the reminder about the timeline for the WP 7.1 release. Let's see how much work is left to iron out all the feedback raised so far. Either way, we want to get some early testing through the AI plugin. All the feedback is appreciated and will help shape this essential ability.

'enum' => $post_types,
'description' => __( 'Optional. Restrict the lookup to this post type; the post is returned only if it matches and the current user can edit it.', 'ai' ),
),
'fields' => $fields,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In both cases, fields can be defined, so you can lift it up as properties always available on the object, next to oneOf.

post_type is more nuanced as it is required in one case but optional in another, so maybe fine as is, but technically it could be lifted up, too.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @gziolo, this would not work because inside each one of branch we have additionalProperties false, so on strict validation the client uses fields would be considered an additional property for oneOf branches and validation would fail.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any LLM consuming this in any way needs to know what posts types are available. Either what @gziolo suggested or inside the filed description or inside the ability description.


return array(
'type' => 'object',
'additionalProperties' => false,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the sake of completeness, all properties should be marked as required:

Suggested change
'additionalProperties' => false,
'additionalProperties' => false,
'required' => [ 'posts', 'total', 'total_pages' ],

@gziolo gziolo mentioned this pull request Jun 24, 2026
3 tasks
'core/content',
array(
'label' => __( 'Get Content', 'ai' ),
'description' => __( 'Retrieves one or more readable posts of a post type exposed to abilities. Fetch a single readable post by ID or by slug, or query multiple readable posts filtered by post type, status, author, or parent. Returns a basic, support-aware set of fields per post, with raw fields limited to users who can edit the post.', 'ai' ),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"readable posts of a post type exposed to abilities." An LLM consuming this ability has no idea what "exposed to abilities" means.
This should contain the available posts types or instruct how it can get a list of available post types.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another ability that is being developed is called core/manage-content. So something hinting at reading could work here if we need more specificity. search- could work, too.

The title and description should cover the difference already, but if we want to have it also in the name then these options are available.

$statuses = array_values( get_post_stati( array( 'internal' => false ) ) );

wp_register_ability(
'core/content',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

with no verb, this ability name implies any action(CRUD+) on the entire content domain (posts, post-meta, terms, comments, media...)
Suggestion: core/read-content not ideal either, but at least has the verb saying what it does

'content_rendered' => array(
'type' => 'string',
'description' => __( 'The rendered post content. Present when the post type supports the editor. Empty when withheld for a password-protected post.', 'ai' ),
),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we return both content_raw and content_rendered, this will fill up the context window with duplicated content.
Suggestion: have a scope input field(similar to REST API)

  • edit will return the raw content that an agent can modify and update on a new request
  • read it will return the "content_rendered". Probably best if we can strip html tags also (or transformed to MD :D [nice to have]) (I have no strong opinion on this one but with stripped tags we will be nice with the context window)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • read it will return the "content_rendered". Probably best if we can strip html tags also (or transformed to MD :D [nice to have]) (I have no strong opinion on this one but with stripped tags we will be nice with the context window)

Warning

Opinionated 😸

I'd be reluctant to add the required code to core given it would add quite a bit of work to each request.

Dries Buytaert wrote up his experience of providing a markdown version of his content for LLMs and finding that the agents simply crawled both versions, so I think it's pretty safe to assume that they're happy with HTML.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO that article has little to do with Markdown vs HTML and is rather about sources of truth/context rot (LLMs.txt is ineffective) and misunderstandings about how agentic discovery works (nothings going to visit your MD endpoints if you don't explicitly encourage it to).

That aside , there are other implications with exposing raw vs rendered content, including security and end-user case. As abilities are meant to be a primitive and not a MCP-specific transport, I think the way to address that in MCP (or upstream in Abilities) is an arg to filter the output.

If in the short term this is a concern, then I'd say keep content_rendered without content_raw, since even if LLMs do prefer markdown to HTML, they'd want the markdown version of the rendered html, not the one fill with custom block markup or unprocessed synced patterns etc. But longer term, IMO the solution belongs in a different layer.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That aside , there are other implications with exposing raw vs rendered content,

@jasonbahl any chance you have a few minutes to share your thoughts from on over in WPGraphQL-land? With how much influence we took in design language, it would make sense for the output ergonomics to wind up being similar, just maybe a bit flatter. Same considerations, at least 🤔

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In my earlier #739 (comment), I referenced WP CLI as a good example of where good defaults solve the issue you discuss. It could be replicated here by returning the same set: ID, post_title, post_name, post_date, and post_status. All other fields remain accessible, but the consumer needs to explicitly list them in the input using the fields array.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In progress

Development

Successfully merging this pull request may close these issues.

6 participants