feat: MCP Abilities for headless content workflows#1
Conversation
Registers WordPress 6.9 Abilities API endpoints so AI tools (Claude Code, Cursor) and VS Code can manage WordPress content programmatically via MCP, by pairing this plugin with WordPress/mcp-adapter. This is the migrated content from the standalone spoko-mcp-abilities plugin (spokospace/spoko-mcp-abilities PR #1, now archived) - folded back into Enhanced because: - Enhanced already owns the spoko/v1 namespace and the Polylang integration code (PolylangSupport) - Admin Features Management settings page is the natural place for the on/off toggle - One plugin to install/maintain rather than two - Capability checks remain the real security boundary; a second plugin added no real isolation Eleven abilities under category spoko-content: - posts-create / posts-update / posts-delete - pages-create / pages-update / pages-delete - terms-create / terms-update / terms-delete (category + post_tag) - translations-link / translations-unlink (Polylang, gated) Per-ability: - JSON Schema input/output with top-level required arrays (standard form, validated by rest_validate_value_from_schema) - WordPress capability checks in permission_callback, status-aware on create + update (publish_*/edit_* depending on the requested status; ID-bound for edit/delete) - get_post_type() guard so spoko/pages-* cannot mutate posts (and vice versa) despite WPs map_meta_cap aliasing - term_exists(id, taxonomy) guard on terms-update/delete - Translations-link is additive: existing translation groups are merged in; multi-group input is rejected (409); language codes are validated against pll_languages_list(); minProperties=2 - MCP annotations (readonly/destructive/idempotent) New files: - src/Features/Mcp/AbilitiesFeature.php (entry, hook wiring, function_exists + admin toggle gating) - src/Features/Mcp/Posts.php - src/Features/Mcp/Pages.php - src/Features/Mcp/Terms.php - src/Features/Mcp/Translations.php Modified: - src/Core/Plugin.php - wires AbilitiesFeature in initFeatures() and registerGlobalFeatures() - src/Features/AdminInterface.php - new MCP Abilities row in the Features Management card; option in saveFeatureSettings - spoko-enhanced-rest-api.php - bump 1.1.0 -> 1.2.0, expand description - readme.txt / README.md - document the feature Requires WordPress 6.9 for MCP features; falls back to silent no-op on older WP versions. Default: enabled. Toggle: SPOKO REST API -> Features Management -> MCP Abilities.
There was a problem hiding this comment.
Pull request overview
Adds MCP-facing WordPress Abilities API support for headless content workflows, integrating posts/pages/terms CRUD and Polylang translation management into the existing SPOKO REST API plugin.
Changes:
- Adds MCP ability registration, schemas, capability checks, and handlers for content and translation workflows.
- Wires the feature into plugin initialization and the admin Features Management toggle.
- Updates plugin metadata and documentation for version 1.2.0 and MCP usage.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
src/Features/Mcp/AbilitiesFeature.php |
Registers the MCP ability category and conditionally registers abilities. |
src/Features/Mcp/Posts.php |
Adds post create/update/delete abilities. |
src/Features/Mcp/Pages.php |
Adds page create/update/delete abilities. |
src/Features/Mcp/Terms.php |
Adds category/tag create/update/delete abilities. |
src/Features/Mcp/Translations.php |
Adds Polylang translation link/unlink abilities. |
src/Features/AdminInterface.php |
Adds the MCP Abilities admin setting. |
src/Core/Plugin.php |
Wires the MCP feature into plugin registration flow. |
spoko-enhanced-rest-api.php |
Bumps plugin version and description. |
readme.txt |
Documents MCP abilities and changelog entry. |
README.md |
Documents MCP abilities and capability behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| $cap = $tax === 'category' ? 'manage_categories' : 'manage_post_tags'; | ||
|
|
||
| return current_user_can($cap) |
| foreach ($inputMap as $lang => $postId) { | ||
| pll_set_post_language($postId, $lang); | ||
| $merged[$lang] = $postId; |
…t IDs - Terms: use taxonomy cap object (manage_terms) instead of the non-existent manage_post_tags string, so admins can manage default post_tag taxonomy. - Translations: reject inputs where the same post ID is mapped to multiple languages before calling pll_set_post_language, preventing a corrupted translation set from being saved.
| } | ||
|
|
||
| $merged = $distinctGroups ? reset($distinctGroups) : []; | ||
| foreach ($inputMap as $lang => $postId) { |
|
|
||
| public static function update(array $input): array|WP_Error | ||
| { | ||
| $args = ['ID' => (int) $input['id']]; |
|
|
||
| public static function update(array $input): array|WP_Error | ||
| { | ||
| $args = ['ID' => (int) $input['id']]; |
Mirrors the no_updates guard in the terms-update path: if a caller invokes posts-update or pages-update with only an id and no mutable fields, return a 400 instead of letting wp_update_post fire hooks/bump timestamps. For posts, tag_ids is honored independently — a tag-only update is valid and now skips wp_update_post entirely so post-update hooks don't fire when only the tag set changes.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 4 comments.
Comments suppressed due to low confidence (2)
src/Features/Mcp/Posts.php:155
- This ignores the result of
wp_set_post_tags(). Iftag_idscontains an invalid term ID, the update returns success even though the tag assignment failed. Capture the return value and return theWP_Errorso callers do not see a partial update as successful.
wp_set_post_tags($id, array_map('intval', $input['tag_ids']));
src/Features/Mcp/Posts.php:147
- When
category_idsis included,wp_update_post()does not expose failures from the underlying category assignment. Invalid category IDs can make this ability return success after only partially applying the requested update. Validate category IDs before updating, or set categories explicitly and return anyWP_Error.
$result = wp_update_post($args, true);
| } | ||
|
|
||
| if (!empty($input['tag_ids'])) { | ||
| wp_set_post_tags($id, array_map('intval', $input['tag_ids'])); |
| $args['post_category'] = array_map('intval', $input['category_ids']); | ||
| } | ||
|
|
||
| $id = wp_insert_post($args, true); |
| 'output_schema' => self::idSchema(), | ||
| 'permission_callback' => [self::class, 'canDelete'], | ||
| 'execute_callback' => [self::class, 'delete'], | ||
| 'meta' => ['annotations' => ['readonly' => false, 'destructive' => true, 'idempotent' => true]], |
| 'output_schema' => self::idSchema(), | ||
| 'permission_callback' => [self::class, 'canDelete'], | ||
| 'execute_callback' => [self::class, 'delete'], | ||
| 'meta' => ['annotations' => ['readonly' => false, 'destructive' => true, 'idempotent' => true]], |
…nflicts - Posts create/update: validate category_ids and tag_ids up front via a new validateTermIds() helper. Invalid IDs now return 400 before any post is created, avoiding orphan posts and silently-unapplied terms that wp_insert_post / wp_set_post_tags would have hidden. - Posts/Pages delete: when force=false and the post is already trashed, return success without re-deleting. wp_delete_post would otherwise permanently delete on retry, breaking the documented idempotent contract. - Translations link: before merging into an existing group, reject any incoming language whose slot is already held by a different post id with 409 language_slot_taken, instead of silently unlinking the previous post.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (1)
src/Features/Mcp/Pages.php:132
- Updating
post_parentfromparent_idhas the same validation gap as create: a caller can point a page at a non-page post ID. Validate non-zero parent IDs are existing pages before callingwp_update_post()so the ability cannot create an invalid page hierarchy.
if (array_key_exists('parent_id', $input)) {
$args['post_parent'] = (int) $input['parent_id'];
| foreach ($inputMap as $lang => $postId) { | ||
| pll_set_post_language($postId, $lang); | ||
| $merged[$lang] = $postId; |
| if (!empty($input['parent_id'])) { | ||
| $args['post_parent'] = (int) $input['parent_id']; |
- Translations link: also reject when an incoming post is already in the existing group under a different language. Without this check, the merge loop would leave the old slot intact and add the same post under the new language, producing duplicate post IDs across two language slots. Normalized $merged to ints so the strict comparisons are reliable. - Pages create/update: validate non-zero parent_id via new validateParent() helper. wp_insert_post / wp_update_post otherwise accept any post ID as post_parent, allowing an invalid page hierarchy. parent_id=0 stays valid in update (clears the parent).
…ath SEO helper - New Read.php exposes spoko/posts-get, spoko/pages-get, and spoko/posts-list so LLM clients can fetch the full editable shape (title, content, excerpt, taxonomies, parent, featured image, Rank Math SEO) in one ability call. Per-object permission uses WP read_post meta cap; list requires edit_posts. - New SeoMeta helper centralizes Rank Math meta keys (title, description, focus_keyword, canonical_url, robots, og_title, og_description, og_image_id) with a validate/read/write split so callers can fail fast before mutating WP state. - AbilitiesFeature registers Read alongside the existing CRUD abilities and the category description reflects the broader surface.
Extends create and update for both Posts and Pages with two optional fields: - featured_image_id: attachment ID validated upfront via new validateAttachment helper; null on update clears _thumbnail_id. - seo: object of Rank Math fields. Validated upfront via SeoMeta::validate (Rank Math active, og image attachment exists, robots directives in allowlist) before any post mutation, so invalid SEO input can't leave an orphan post or half-applied state. no_updates guard extended to recognize featured_image_id and seo as valid mutations so a request setting only those isn't rejected as a no-op.
Solves the headless Polylang Pro pain point where translating a post leaves the new language version pointing at source-language attachments, forcing manual "Add translation" clicks on every image before it can be reused. - New MediaTranslator helper: ensureTranslation(attId, target, source) duplicates an attachment record sharing the same file (_wp_attached_file, _wp_attachment_metadata, alt) and links it into the Polylang translation group. No-op when Polylang media translations are disabled. rewriteContent parses Gutenberg blocks and patches id/ids on core/image, core/cover, core/media-text, core/gallery, plus the wp-image-NNN class references. - New spoko/posts-translate ability: copies a source post into a new language, applies optional overrides (title/content/excerpt/slug/status/ featured_image_id), translates featured + in-content media, and links the pair via Polylang. Rejects same-language requests, missing source language, and existing translations with a 409. Returns the new post id plus a per-source media_translations report so callers can audit what was created. - Registered in AbilitiesFeature alongside the existing Polylang-gated Translations ability.
Posts created before posts-translate existed can have featured image and in-content attachments tagged with the wrong Polylang language (or no language at all). spoko/attachments-rewire fixes them in place. - Refactored MediaTranslator to extract a shared walkContent + walkBlocks helper that both rewriteContent (translate-copy) and the new rewireContentForLanguage (backfill) use through a resolver closure. Pulled the attachment-duplication logic into duplicateAttachment so ensureTranslation and rewireForLanguage share it. - rewireForLanguage(attId, postLang) has three branches: same-language is a no-op, no-language attachment gets the post's lang assigned in place (avoiding a needless duplicate), other-language attachment is swapped for the matching translation (created on demand via duplicateAttachment). - New Rewire ability accepts post or page IDs, scans featured image and Gutenberg blocks, and returns a per-attachment report. wp_update_post is skipped when the content body didn't change so already-correct posts truly no-op.
Summary
Adds WordPress 6.9 Abilities API endpoints to Enhanced-WP-REST-API so AI tools (Claude Code, Cursor) and VS Code can manage WordPress content programmatically via MCP. Pair this plugin with WordPress/mcp-adapter to expose the abilities over MCP.
This work was previously developed as a standalone plugin (spokospace/spoko-mcp-abilities#1) — that repo will be archived in favor of this consolidated PR. Reasons for folding it back here:
spoko/v1namespace and the Polylang integration (PolylangSupport).Eleven abilities (category
spoko-content)spoko/posts-createpublish_postsfor publish/private/future, elseedit_postsspoko/posts-updateedit_post(id)post_typeguard; extrapublish_postswhen transitioning to a published statespoko/posts-deletedelete_post(id)post_typeguardspoko/pages-createpublish_pagesfor publish/private/future, elseedit_pagesspoko/pages-updateedit_page(id)post_typeguard; extrapublish_pagescheck on publish transitionspoko/pages-deletedelete_page(id)post_typeguardspoko/terms-createmanage_categories/manage_post_tagsspoko/terms-updatespoko/terms-deletespoko/translations-linkedit_posts+edit_post(id)per entryminProperties: 2spoko/translations-unlinkedit_post(id)Each ability:
requiredarrays (standard form, validated byrest_validate_value_from_schema).permission_callback.readonly/destructive/idempotent) for downstream MCP tooling.Files
New:
src/Features/Mcp/AbilitiesFeature.php— entry; hookswp_abilities_api_categories_init+wp_abilities_api_init, gated byfunction_existsand admin toggle.src/Features/Mcp/Posts.php,Pages.php,Terms.php,Translations.php.Modified:
src/Core/Plugin.php— wiresAbilitiesFeatureintoinitFeatures()andregisterGlobalFeatures().src/Features/AdminInterface.php— new "MCP Abilities" row in Features Management; option added tosaveFeatureSettings.spoko-enhanced-rest-api.php— version bump 1.1.0 → 1.2.0, expanded description.readme.txt/README.md— feature documentation, tag updates, changelog entry.Compatibility
function_exists('wp_register_ability')returns false) — the rest of the plugin works unchanged.Verification
Activated locally on WordPress 6.9.4 with Polylang Pro and
WordPress/mcp-adapter0.5.0:Test plan
@automattic/mcp-wordpress-remoteto a WP 6.9+ site with this branch +mcp-adapter. Confirm all 11 abilities are exposed.spoko/translations-link {"en": A, "pl": B}→ verify pairing in wp-admin and viatranslations_datafield.spoko/*in the registry).publish_posts) →spoko/posts-create {status: "publish"}→ expect 403.spoko/posts-create {status: "draft"}→ expect success.spoko/pages-update {id: <post-id>}→ expect 404not_a_page.spoko/translations-link {en: A, pl: B}→ expect 409multiple_translation_groups.spoko/translations-*abilities are absent and the rest still register.