Skip to content

feat: MCP Abilities for headless content workflows#1

Open
spokospace wants to merge 9 commits into
mainfrom
feat/mcp-abilities
Open

feat: MCP Abilities for headless content workflows#1
spokospace wants to merge 9 commits into
mainfrom
feat/mcp-abilities

Conversation

@spokospace
Copy link
Copy Markdown
Owner

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:

  • Enhanced already owns the spoko/v1 namespace and the Polylang integration (PolylangSupport).
  • The admin Features Management settings page is the natural home for the on/off toggle.
  • One plugin to install and maintain rather than two.
  • Capability checks per-endpoint remain the real security boundary — a separate plugin added no real isolation.

Eleven abilities (category spoko-content)

Ability Capability Notes
spoko/posts-create publish_posts for publish/private/future, else edit_posts status-aware
spoko/posts-update edit_post(id) post_type guard; extra publish_posts when transitioning to a published state
spoko/posts-delete delete_post(id) post_type guard
spoko/pages-create publish_pages for publish/private/future, else edit_pages status-aware
spoko/pages-update edit_page(id) post_type guard; extra publish_pages check on publish transition
spoko/pages-delete delete_page(id) post_type guard
spoko/terms-create manage_categories / manage_post_tags
spoko/terms-update same rejects when term ID is not in the given taxonomy
spoko/terms-delete same same
spoko/translations-link edit_posts + edit_post(id) per entry additive, rejects multi-group fusion, validates language codes, minProperties: 2
spoko/translations-unlink edit_post(id)

Each ability:

  • Declares JSON Schema input/output with top-level required arrays (standard form, validated by rest_validate_value_from_schema).
  • Enforces WP capabilities in permission_callback.
  • Carries MCP annotations (readonly / destructive / idempotent) for downstream MCP tooling.

Files

New:

  • src/Features/Mcp/AbilitiesFeature.php — entry; hooks wp_abilities_api_categories_init + wp_abilities_api_init, gated by function_exists and admin toggle.
  • src/Features/Mcp/Posts.php, Pages.php, Terms.php, Translations.php.

Modified:

  • src/Core/Plugin.php — wires AbilitiesFeature into initFeatures() and registerGlobalFeatures().
  • src/Features/AdminInterface.php — new "MCP Abilities" row in Features Management; option added to saveFeatureSettings.
  • 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

  • Requires WordPress 6.9+ for the MCP feature only. On older WP the feature self-disables (function_exists('wp_register_ability') returns false) — the rest of the plugin works unchanged.
  • Polylang abilities are only registered when Polylang is active.
  • Default toggle: enabled. Admins can disable from SPOKO REST API → Features Management → MCP Abilities.

Verification

Activated locally on WordPress 6.9.4 with Polylang Pro and WordPress/mcp-adapter 0.5.0:

SPOKO abilities: 11
  - spoko/posts-create
  - spoko/posts-update
  - spoko/posts-delete
  - spoko/pages-create
  - spoko/pages-update
  - spoko/pages-delete
  - spoko/terms-create
  - spoko/terms-update
  - spoko/terms-delete
  - spoko/translations-link
  - spoko/translations-unlink
Toggle option (mcp_abilities_enabled): '1'
Plugin version: 1.2.0

Test plan

  • Connect Claude Code via @automattic/mcp-wordpress-remote to a WP 6.9+ site with this branch + mcp-adapter. Confirm all 11 abilities are exposed.
  • Create EN post → create PL post → spoko/translations-link {"en": A, "pl": B} → verify pairing in wp-admin and via translations_data field.
  • Toggle off in admin → confirm abilities no longer register (no spoko/* in the registry).
  • Capability check: log in as Author (no publish_posts) → spoko/posts-create {status: "publish"} → expect 403. spoko/posts-create {status: "draft"} → expect success.
  • Post-type guard: spoko/pages-update {id: <post-id>} → expect 404 not_a_page.
  • Multi-group: link A∈{en,de}, B∈{pl,fr} via spoko/translations-link {en: A, pl: B} → expect 409 multiple_translation_groups.
  • Manually deactivate Polylang → verify spoko/translations-* abilities are absent and the rest still register.

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.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

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.

Comment thread src/Features/Mcp/Terms.php Outdated
Comment on lines +56 to +58
$cap = $tax === 'category' ? 'manage_categories' : 'manage_post_tags';

return current_user_can($cap)
Comment on lines +128 to +130
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.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated 3 comments.

}

$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.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

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(). If tag_ids contains an invalid term ID, the update returns success even though the tag assignment failed. Capture the return value and return the WP_Error so 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_ids is 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 any WP_Error.
            $result = wp_update_post($args, true);

Comment thread src/Features/Mcp/Posts.php Outdated
}

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.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

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_parent from parent_id has 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 calling wp_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'];

Comment on lines +150 to +152
foreach ($inputMap as $lang => $postId) {
pll_set_post_language($postId, $lang);
$merged[$lang] = $postId;
Comment thread src/Features/Mcp/Pages.php Outdated
Comment on lines +103 to +104
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants