From 35e6f47d8b96cf83558b7fd7240adb3b82ddf21b Mon Sep 17 00:00:00 2001
From: Szymon
Date: Mon, 18 May 2026 02:57:14 +0200
Subject: [PATCH 1/9] feat: MCP Abilities for headless content workflows
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.
---
README.md | 21 +++
readme.txt | 27 +++-
spoko-enhanced-rest-api.php | 4 +-
src/Core/Plugin.php | 10 +-
src/Features/AdminInterface.php | 24 ++-
src/Features/Mcp/AbilitiesFeature.php | 55 +++++++
src/Features/Mcp/Pages.php | 207 ++++++++++++++++++++++++
src/Features/Mcp/Posts.php | 218 ++++++++++++++++++++++++++
src/Features/Mcp/Terms.php | 185 ++++++++++++++++++++++
src/Features/Mcp/Translations.php | 154 ++++++++++++++++++
10 files changed, 895 insertions(+), 10 deletions(-)
create mode 100644 src/Features/Mcp/AbilitiesFeature.php
create mode 100644 src/Features/Mcp/Pages.php
create mode 100644 src/Features/Mcp/Posts.php
create mode 100644 src/Features/Mcp/Terms.php
create mode 100644 src/Features/Mcp/Translations.php
diff --git a/README.md b/README.md
index 0fe2528..8a0a69d 100644
--- a/README.md
+++ b/README.md
@@ -61,6 +61,27 @@ Configurable via admin panel:
- **Anonymous Comments** - Allow posting comments via REST API without authentication
- **Comment Notifications** - Automatic email notifications to moderators when new comments are created via REST API
+### MCP Abilities (Optional, requires WordPress 6.9+)
+Registers WordPress 6.9 Abilities API endpoints for headless content workflows. Pair with [WordPress/mcp-adapter](https://github.com/WordPress/mcp-adapter) to expose them over MCP so AI tools (Claude Code, Cursor) and VS Code can manage content programmatically.
+
+Eleven abilities under category `spoko-content`:
+
+| Ability | Capability | Notes |
+|---|---|---|
+| `spoko/posts-create` | `publish_posts` for publish/private/future, else `edit_posts` | |
+| `spoko/posts-update` | `edit_post` on the target ID | post_type guard; extra `publish_posts` check when transitioning to publish |
+| `spoko/posts-delete` | `delete_post` on the target ID | post_type guard |
+| `spoko/pages-create` | `publish_pages` for publish/private/future, else `edit_pages` | |
+| `spoko/pages-update` | `edit_page` on the target ID | post_type guard; extra `publish_pages` check when transitioning to publish |
+| `spoko/pages-delete` | `delete_page` on the target 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 | |
+| `spoko/translations-link` | `edit_posts` + `edit_post` on every input ID | additive; rejects multi-group fusion; validates language codes against Polylang |
+| `spoko/translations-unlink` | `edit_post` on the target ID | |
+
+Each ability validates input/output via JSON Schema, enforces WordPress capabilities in `permission_callback`, and carries MCP annotations (`readonly` / `destructive` / `idempotent`). Toggleable from **SPOKO REST API → Features Management → MCP Abilities**. Silently no-ops on WordPress < 6.9.
+
### Headless Mode (Optional)
Complete headless WordPress functionality:
- **Frontend Redirect** - Automatically redirects all visitors to your headless frontend application
diff --git a/readme.txt b/readme.txt
index 2ef3f17..acd595b 100644
--- a/readme.txt
+++ b/readme.txt
@@ -1,10 +1,10 @@
=== SPOKO Enhanced WP REST API ===
Contributors: spoko
-Tags: rest-api, headless, cms, api, polylang, multilingual, astro, nextjs
+Tags: rest-api, headless, cms, api, polylang, multilingual, astro, nextjs, mcp, abilities-api, ai
Requires at least: 5.0
-Tested up to: 6.7
+Tested up to: 6.9
Requires PHP: 8.3
-Stable tag: 1.0.8
+Stable tag: 1.2.0
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html
@@ -82,6 +82,19 @@ Configurable via admin panel:
* Anonymous Comments - Allow posting comments via REST API without authentication
* Comment Notifications - Automatic email notifications to moderators when new comments are created via REST API
+**MCP Abilities (Optional, requires WordPress 6.9+)**
+
+Registers WordPress 6.9 Abilities API endpoints for headless content workflows. Pair with [WordPress/mcp-adapter](https://github.com/WordPress/mcp-adapter) to expose them over MCP so AI tools (Claude Code, Cursor) and VS Code can manage content programmatically.
+
+Eleven abilities under category `spoko-content`:
+
+* `spoko/posts-create`, `posts-update`, `posts-delete` — CRUD on posts
+* `spoko/pages-create`, `pages-update`, `pages-delete` — CRUD on pages
+* `spoko/terms-create`, `terms-update`, `terms-delete` — categories and tags
+* `spoko/translations-link`, `translations-unlink` — Polylang translation pairing (registered only when Polylang is active)
+
+Each ability validates input/output via JSON Schema, enforces WordPress capabilities in `permission_callback` (status-aware for posts/pages), and carries MCP annotations (`readonly` / `destructive` / `idempotent`). The feature is toggleable from the admin settings page and silently no-ops on WordPress < 6.9.
+
**Headless Mode (Optional)**
Complete headless WordPress functionality:
@@ -148,6 +161,14 @@ No, the plugin is optimized for performance and only adds minimal processing to
== Changelog ==
+= 1.2.0 =
+* Added: MCP Abilities feature — eleven Abilities API endpoints (posts/pages/terms CRUD + Polylang translation pairing) for headless content workflows
+* Added: Admin toggle "MCP Abilities" in Features Management
+* Feature: Status-aware capability checks on post/page create and update
+* Feature: Post-type guard prevents cross-post-type mutation
+* Feature: Polylang translation linking is additive and rejects multi-group fusion
+* Requires WordPress 6.9+ for MCP features (silently disabled on older versions); rest of plugin unchanged
+
= 1.0.8 =
* Added: Headless Mode - Complete headless WordPress functionality
* Added: Frontend redirect with URL path preservation
diff --git a/spoko-enhanced-rest-api.php b/spoko-enhanced-rest-api.php
index 13e3934..99fc8cc 100644
--- a/spoko-enhanced-rest-api.php
+++ b/spoko-enhanced-rest-api.php
@@ -1,8 +1,8 @@
cache),
new CategoryFeaturedImage(),
new PostCounters($this->logger),
- new MenusEndpoint()
+ new MenusEndpoint(),
+ new AbilitiesFeature(),
];
}
@@ -89,7 +91,7 @@ public function registerGlobalFeatures(): void
foreach ($this->features as $feature) {
// Only register HeadlessMode, AdminInterface and CategoryFeaturedImage here
// Other features will be registered via their specific hooks
- if ($feature instanceof HeadlessMode || $feature instanceof AdminInterface || $feature instanceof CategoryFeaturedImage) {
+ if ($feature instanceof HeadlessMode || $feature instanceof AdminInterface || $feature instanceof CategoryFeaturedImage || $feature instanceof AbilitiesFeature) {
if (method_exists($feature, 'register')) {
$feature->register();
}
@@ -101,7 +103,7 @@ public function registerRestFields(): void
{
foreach ($this->features as $feature) {
// Skip features already registered globally
- if ($feature instanceof HeadlessMode || $feature instanceof AdminInterface || $feature instanceof CategoryFeaturedImage) {
+ if ($feature instanceof HeadlessMode || $feature instanceof AdminInterface || $feature instanceof CategoryFeaturedImage || $feature instanceof AbilitiesFeature) {
continue;
}
@@ -127,7 +129,7 @@ public function registerAdminFeatures(): void
{
foreach ($this->features as $feature) {
// Skip features already registered globally
- if ($feature instanceof HeadlessMode || $feature instanceof AdminInterface || $feature instanceof CategoryFeaturedImage) {
+ if ($feature instanceof HeadlessMode || $feature instanceof AdminInterface || $feature instanceof CategoryFeaturedImage || $feature instanceof AbilitiesFeature) {
continue;
}
diff --git a/src/Features/AdminInterface.php b/src/Features/AdminInterface.php
index 20e85d0..71beeef 100644
--- a/src/Features/AdminInterface.php
+++ b/src/Features/AdminInterface.php
@@ -4,6 +4,7 @@
namespace Spoko\EnhancedRestAPI\Features;
+use Spoko\EnhancedRestAPI\Features\Mcp\AbilitiesFeature;
use Spoko\EnhancedRestAPI\Services\TranslationCache;
class AdminInterface
@@ -152,7 +153,8 @@ private function saveFeatureSettings(): void
'spoko_rest_relative_urls_enabled',
'spoko_rest_anonymous_comments_enabled',
'spoko_rest_comment_notifications_enabled',
- 'spoko_rest_post_counters_enabled'
+ 'spoko_rest_post_counters_enabled',
+ AbilitiesFeature::OPTION_ENABLED,
];
foreach ($features as $feature) {
@@ -399,6 +401,26 @@ public function renderAdminPage(): void
+
+
+
MCP Abilities
+
+
+
+ Exposes content CRUD (posts, pages, categories, tags) and Polylang translation pairing as
+ WordPress Abilities API
+ endpoints under category spoko-content. Pair with
+ mcp-adapter to expose them over MCP for Claude Code / Cursor / VS Code.
+ Requires WordPress 6.9+ — silently no-ops on older versions.
+
+
+
diff --git a/src/Features/Mcp/AbilitiesFeature.php b/src/Features/Mcp/AbilitiesFeature.php
new file mode 100644
index 0000000..acbb7a9
--- /dev/null
+++ b/src/Features/Mcp/AbilitiesFeature.php
@@ -0,0 +1,55 @@
+ __('SPOKO content workflows', 'spoko-enhanced-rest-api'),
+ 'description' => __('Headless content management: posts/pages/terms CRUD and Polylang translation pairing.', 'spoko-enhanced-rest-api'),
+ ]
+ );
+ }
+
+ public function registerAbilities(): void
+ {
+ if (!function_exists('wp_register_ability')) {
+ return;
+ }
+
+ Posts::register();
+ Pages::register();
+ Terms::register();
+
+ if (function_exists('pll_set_post_language') && function_exists('pll_save_post_translations')) {
+ Translations::register();
+ }
+ }
+}
diff --git a/src/Features/Mcp/Pages.php b/src/Features/Mcp/Pages.php
new file mode 100644
index 0000000..c4bb6bc
--- /dev/null
+++ b/src/Features/Mcp/Pages.php
@@ -0,0 +1,207 @@
+ __('Create page', 'spoko-enhanced-rest-api'),
+ 'description' => __('Create a WordPress page with title, content, status, slug, and optional parent ID.', 'spoko-enhanced-rest-api'),
+ 'category' => 'spoko-content',
+ 'input_schema' => self::createInputSchema(),
+ 'output_schema' => self::idSchema(),
+ 'permission_callback' => [self::class, 'canCreate'],
+ 'execute_callback' => [self::class, 'create'],
+ 'meta' => ['annotations' => ['readonly' => false, 'destructive' => false, 'idempotent' => false]],
+ ]);
+
+ wp_register_ability('spoko/pages-update', [
+ 'label' => __('Update page', 'spoko-enhanced-rest-api'),
+ 'description' => __('Update an existing page by ID. Only provided fields are changed.', 'spoko-enhanced-rest-api'),
+ 'category' => 'spoko-content',
+ 'input_schema' => self::updateInputSchema(),
+ 'output_schema' => self::idSchema(),
+ 'permission_callback' => [self::class, 'canEdit'],
+ 'execute_callback' => [self::class, 'update'],
+ 'meta' => ['annotations' => ['readonly' => false, 'destructive' => false, 'idempotent' => true]],
+ ]);
+
+ wp_register_ability('spoko/pages-delete', [
+ 'label' => __('Delete page', 'spoko-enhanced-rest-api'),
+ 'description' => __('Move a page to trash, or permanently delete with force=true.', 'spoko-enhanced-rest-api'),
+ 'category' => 'spoko-content',
+ 'input_schema' => self::deleteInputSchema(),
+ 'output_schema' => self::idSchema(),
+ 'permission_callback' => [self::class, 'canDelete'],
+ 'execute_callback' => [self::class, 'delete'],
+ 'meta' => ['annotations' => ['readonly' => false, 'destructive' => true, 'idempotent' => true]],
+ ]);
+ }
+
+ public static function canCreate(array $input): bool|WP_Error
+ {
+ $status = (string) ($input['status'] ?? 'draft');
+ $cap = in_array($status, self::PUBLISH_STATUSES, true) ? 'publish_pages' : 'edit_pages';
+
+ return current_user_can($cap)
+ ? true
+ : new WP_Error('forbidden', __('You cannot create pages with this status.', 'spoko-enhanced-rest-api'), ['status' => 403]);
+ }
+
+ public static function canEdit(array $input): bool|WP_Error
+ {
+ $id = (int) ($input['id'] ?? 0);
+ if (get_post_type($id) !== 'page') {
+ return new WP_Error('not_a_page', __('Target is not a page.', 'spoko-enhanced-rest-api'), ['status' => 404]);
+ }
+ if (!current_user_can('edit_page', $id)) {
+ return new WP_Error('forbidden', __('You cannot edit this page.', 'spoko-enhanced-rest-api'), ['status' => 403]);
+ }
+ if (array_key_exists('status', $input)
+ && in_array((string) $input['status'], self::PUBLISH_STATUSES, true)
+ && !current_user_can('publish_pages')
+ ) {
+ return new WP_Error('forbidden', __('You cannot transition a page to a published state.', 'spoko-enhanced-rest-api'), ['status' => 403]);
+ }
+
+ return true;
+ }
+
+ public static function canDelete(array $input): bool|WP_Error
+ {
+ $id = (int) ($input['id'] ?? 0);
+ if (get_post_type($id) !== 'page') {
+ return new WP_Error('not_a_page', __('Target is not a page.', 'spoko-enhanced-rest-api'), ['status' => 404]);
+ }
+
+ return current_user_can('delete_page', $id)
+ ? true
+ : new WP_Error('forbidden', __('You cannot delete this page.', 'spoko-enhanced-rest-api'), ['status' => 403]);
+ }
+
+ public static function create(array $input): array|WP_Error
+ {
+ $args = [
+ 'post_type' => 'page',
+ 'post_title' => (string) $input['title'],
+ 'post_content' => (string) ($input['content'] ?? ''),
+ 'post_status' => (string) ($input['status'] ?? 'draft'),
+ ];
+ foreach (['slug' => 'post_name', 'excerpt' => 'post_excerpt'] as $in => $field) {
+ if (array_key_exists($in, $input)) {
+ $args[$field] = (string) $input[$in];
+ }
+ }
+ if (!empty($input['parent_id'])) {
+ $args['post_parent'] = (int) $input['parent_id'];
+ }
+
+ $id = wp_insert_post($args, true);
+ if (is_wp_error($id)) {
+ return $id;
+ }
+
+ return ['id' => (int) $id];
+ }
+
+ public static function update(array $input): array|WP_Error
+ {
+ $args = ['ID' => (int) $input['id']];
+
+ $map = [
+ 'title' => 'post_title',
+ 'content' => 'post_content',
+ 'status' => 'post_status',
+ 'slug' => 'post_name',
+ 'excerpt' => 'post_excerpt',
+ ];
+ foreach ($map as $in => $field) {
+ if (array_key_exists($in, $input)) {
+ $args[$field] = (string) $input[$in];
+ }
+ }
+ if (array_key_exists('parent_id', $input)) {
+ $args['post_parent'] = (int) $input['parent_id'];
+ }
+
+ $id = wp_update_post($args, true);
+ if (is_wp_error($id)) {
+ return $id;
+ }
+
+ return ['id' => (int) $id];
+ }
+
+ public static function delete(array $input): array|WP_Error
+ {
+ $result = wp_delete_post((int) $input['id'], (bool) ($input['force'] ?? false));
+ if (!$result) {
+ return new WP_Error('delete_failed', __('Could not delete page.', 'spoko-enhanced-rest-api'));
+ }
+
+ return ['id' => (int) $input['id']];
+ }
+
+ private static function createInputSchema(): array
+ {
+ return [
+ 'type' => 'object',
+ 'required' => ['title'],
+ 'properties' => [
+ 'title' => ['type' => 'string', 'minLength' => 1],
+ 'content' => ['type' => 'string'],
+ 'excerpt' => ['type' => 'string'],
+ 'status' => ['type' => 'string', 'enum' => ['draft', 'publish', 'pending', 'private', 'future']],
+ 'slug' => ['type' => 'string'],
+ 'parent_id' => ['type' => 'integer', 'minimum' => 0],
+ ],
+ ];
+ }
+
+ private static function updateInputSchema(): array
+ {
+ return [
+ 'type' => 'object',
+ 'required' => ['id'],
+ 'properties' => [
+ 'id' => ['type' => 'integer', 'minimum' => 1],
+ 'title' => ['type' => 'string', 'minLength' => 1],
+ 'content' => ['type' => 'string'],
+ 'excerpt' => ['type' => 'string'],
+ 'status' => ['type' => 'string', 'enum' => ['draft', 'publish', 'pending', 'private', 'future']],
+ 'slug' => ['type' => 'string'],
+ 'parent_id' => ['type' => 'integer', 'minimum' => 0],
+ ],
+ ];
+ }
+
+ private static function deleteInputSchema(): array
+ {
+ return [
+ 'type' => 'object',
+ 'required' => ['id'],
+ 'properties' => [
+ 'id' => ['type' => 'integer', 'minimum' => 1],
+ 'force' => ['type' => 'boolean'],
+ ],
+ ];
+ }
+
+ private static function idSchema(): array
+ {
+ return [
+ 'type' => 'object',
+ 'properties' => [
+ 'id' => ['type' => 'integer'],
+ ],
+ ];
+ }
+}
diff --git a/src/Features/Mcp/Posts.php b/src/Features/Mcp/Posts.php
new file mode 100644
index 0000000..3392b95
--- /dev/null
+++ b/src/Features/Mcp/Posts.php
@@ -0,0 +1,218 @@
+ __('Create post', 'spoko-enhanced-rest-api'),
+ 'description' => __('Create a WordPress post with title, content, status, slug, and optional category/tag IDs.', 'spoko-enhanced-rest-api'),
+ 'category' => 'spoko-content',
+ 'input_schema' => self::createInputSchema(),
+ 'output_schema' => self::idSchema(),
+ 'permission_callback' => [self::class, 'canCreate'],
+ 'execute_callback' => [self::class, 'create'],
+ 'meta' => ['annotations' => ['readonly' => false, 'destructive' => false, 'idempotent' => false]],
+ ]);
+
+ wp_register_ability('spoko/posts-update', [
+ 'label' => __('Update post', 'spoko-enhanced-rest-api'),
+ 'description' => __('Update an existing post by ID. Only provided fields are changed.', 'spoko-enhanced-rest-api'),
+ 'category' => 'spoko-content',
+ 'input_schema' => self::updateInputSchema(),
+ 'output_schema' => self::idSchema(),
+ 'permission_callback' => [self::class, 'canEdit'],
+ 'execute_callback' => [self::class, 'update'],
+ 'meta' => ['annotations' => ['readonly' => false, 'destructive' => false, 'idempotent' => true]],
+ ]);
+
+ wp_register_ability('spoko/posts-delete', [
+ 'label' => __('Delete post', 'spoko-enhanced-rest-api'),
+ 'description' => __('Move a post to trash, or permanently delete with force=true.', 'spoko-enhanced-rest-api'),
+ 'category' => 'spoko-content',
+ 'input_schema' => self::deleteInputSchema(),
+ 'output_schema' => self::idSchema(),
+ 'permission_callback' => [self::class, 'canDelete'],
+ 'execute_callback' => [self::class, 'delete'],
+ 'meta' => ['annotations' => ['readonly' => false, 'destructive' => true, 'idempotent' => true]],
+ ]);
+ }
+
+ public static function canCreate(array $input): bool|WP_Error
+ {
+ $status = (string) ($input['status'] ?? 'draft');
+ $cap = in_array($status, self::PUBLISH_STATUSES, true) ? 'publish_posts' : 'edit_posts';
+
+ return current_user_can($cap)
+ ? true
+ : new WP_Error('forbidden', __('You cannot create posts with this status.', 'spoko-enhanced-rest-api'), ['status' => 403]);
+ }
+
+ public static function canEdit(array $input): bool|WP_Error
+ {
+ $id = (int) ($input['id'] ?? 0);
+ if (get_post_type($id) !== 'post') {
+ return new WP_Error('not_a_post', __('Target is not a post.', 'spoko-enhanced-rest-api'), ['status' => 404]);
+ }
+ if (!current_user_can('edit_post', $id)) {
+ return new WP_Error('forbidden', __('You cannot edit this post.', 'spoko-enhanced-rest-api'), ['status' => 403]);
+ }
+ if (array_key_exists('status', $input)
+ && in_array((string) $input['status'], self::PUBLISH_STATUSES, true)
+ && !current_user_can('publish_posts')
+ ) {
+ return new WP_Error('forbidden', __('You cannot transition a post to a published state.', 'spoko-enhanced-rest-api'), ['status' => 403]);
+ }
+
+ return true;
+ }
+
+ public static function canDelete(array $input): bool|WP_Error
+ {
+ $id = (int) ($input['id'] ?? 0);
+ if (get_post_type($id) !== 'post') {
+ return new WP_Error('not_a_post', __('Target is not a post.', 'spoko-enhanced-rest-api'), ['status' => 404]);
+ }
+
+ return current_user_can('delete_post', $id)
+ ? true
+ : new WP_Error('forbidden', __('You cannot delete this post.', 'spoko-enhanced-rest-api'), ['status' => 403]);
+ }
+
+ public static function create(array $input): array|WP_Error
+ {
+ $args = [
+ 'post_type' => 'post',
+ 'post_title' => (string) $input['title'],
+ 'post_content' => (string) ($input['content'] ?? ''),
+ 'post_status' => (string) ($input['status'] ?? 'draft'),
+ ];
+ foreach (['slug' => 'post_name', 'excerpt' => 'post_excerpt'] as $in => $field) {
+ if (array_key_exists($in, $input)) {
+ $args[$field] = (string) $input[$in];
+ }
+ }
+ if (!empty($input['category_ids'])) {
+ $args['post_category'] = array_map('intval', $input['category_ids']);
+ }
+
+ $id = wp_insert_post($args, true);
+ if (is_wp_error($id)) {
+ return $id;
+ }
+
+ if (!empty($input['tag_ids'])) {
+ wp_set_post_tags($id, array_map('intval', $input['tag_ids']));
+ }
+
+ return ['id' => (int) $id];
+ }
+
+ public static function update(array $input): array|WP_Error
+ {
+ $args = ['ID' => (int) $input['id']];
+
+ $map = [
+ 'title' => 'post_title',
+ 'content' => 'post_content',
+ 'status' => 'post_status',
+ 'slug' => 'post_name',
+ 'excerpt' => 'post_excerpt',
+ ];
+ foreach ($map as $in => $field) {
+ if (array_key_exists($in, $input)) {
+ $args[$field] = (string) $input[$in];
+ }
+ }
+
+ if (array_key_exists('category_ids', $input)) {
+ $args['post_category'] = array_map('intval', $input['category_ids']);
+ }
+
+ $id = wp_update_post($args, true);
+ if (is_wp_error($id)) {
+ return $id;
+ }
+
+ if (array_key_exists('tag_ids', $input)) {
+ wp_set_post_tags((int) $input['id'], array_map('intval', $input['tag_ids']));
+ }
+
+ return ['id' => (int) $id];
+ }
+
+ public static function delete(array $input): array|WP_Error
+ {
+ $result = wp_delete_post((int) $input['id'], (bool) ($input['force'] ?? false));
+ if (!$result) {
+ return new WP_Error('delete_failed', __('Could not delete post.', 'spoko-enhanced-rest-api'));
+ }
+
+ return ['id' => (int) $input['id']];
+ }
+
+ private static function createInputSchema(): array
+ {
+ return [
+ 'type' => 'object',
+ 'required' => ['title'],
+ 'properties' => [
+ 'title' => ['type' => 'string', 'minLength' => 1],
+ 'content' => ['type' => 'string'],
+ 'excerpt' => ['type' => 'string'],
+ 'status' => ['type' => 'string', 'enum' => ['draft', 'publish', 'pending', 'private', 'future']],
+ 'slug' => ['type' => 'string'],
+ 'category_ids' => ['type' => 'array', 'items' => ['type' => 'integer']],
+ 'tag_ids' => ['type' => 'array', 'items' => ['type' => 'integer']],
+ ],
+ ];
+ }
+
+ private static function updateInputSchema(): array
+ {
+ return [
+ 'type' => 'object',
+ 'required' => ['id'],
+ 'properties' => [
+ 'id' => ['type' => 'integer', 'minimum' => 1],
+ 'title' => ['type' => 'string', 'minLength' => 1],
+ 'content' => ['type' => 'string'],
+ 'excerpt' => ['type' => 'string'],
+ 'status' => ['type' => 'string', 'enum' => ['draft', 'publish', 'pending', 'private', 'future']],
+ 'slug' => ['type' => 'string'],
+ 'category_ids' => ['type' => 'array', 'items' => ['type' => 'integer']],
+ 'tag_ids' => ['type' => 'array', 'items' => ['type' => 'integer']],
+ ],
+ ];
+ }
+
+ private static function deleteInputSchema(): array
+ {
+ return [
+ 'type' => 'object',
+ 'required' => ['id'],
+ 'properties' => [
+ 'id' => ['type' => 'integer', 'minimum' => 1],
+ 'force' => ['type' => 'boolean', 'description' => 'If true, permanently delete instead of moving to trash.'],
+ ],
+ ];
+ }
+
+ private static function idSchema(): array
+ {
+ return [
+ 'type' => 'object',
+ 'properties' => [
+ 'id' => ['type' => 'integer'],
+ ],
+ ];
+ }
+}
diff --git a/src/Features/Mcp/Terms.php b/src/Features/Mcp/Terms.php
new file mode 100644
index 0000000..78ea480
--- /dev/null
+++ b/src/Features/Mcp/Terms.php
@@ -0,0 +1,185 @@
+ __('Create term', 'spoko-enhanced-rest-api'),
+ 'description' => __('Create a category or tag.', 'spoko-enhanced-rest-api'),
+ 'category' => 'spoko-content',
+ 'input_schema' => self::createInputSchema(),
+ 'output_schema' => self::idSchema(),
+ 'permission_callback' => [self::class, 'permission'],
+ 'execute_callback' => [self::class, 'create'],
+ 'meta' => ['annotations' => ['readonly' => false, 'destructive' => false, 'idempotent' => false]],
+ ]);
+
+ wp_register_ability('spoko/terms-update', [
+ 'label' => __('Update term', 'spoko-enhanced-rest-api'),
+ 'description' => __('Update name/slug/description/parent of a category or tag.', 'spoko-enhanced-rest-api'),
+ 'category' => 'spoko-content',
+ 'input_schema' => self::updateInputSchema(),
+ 'output_schema' => self::idSchema(),
+ 'permission_callback' => [self::class, 'permission'],
+ 'execute_callback' => [self::class, 'update'],
+ 'meta' => ['annotations' => ['readonly' => false, 'destructive' => false, 'idempotent' => true]],
+ ]);
+
+ wp_register_ability('spoko/terms-delete', [
+ 'label' => __('Delete term', 'spoko-enhanced-rest-api'),
+ 'description' => __('Delete a category or tag.', 'spoko-enhanced-rest-api'),
+ 'category' => 'spoko-content',
+ 'input_schema' => self::deleteInputSchema(),
+ 'output_schema' => self::idSchema(),
+ 'permission_callback' => [self::class, 'permission'],
+ 'execute_callback' => [self::class, 'delete'],
+ 'meta' => ['annotations' => ['readonly' => false, 'destructive' => true, 'idempotent' => true]],
+ ]);
+ }
+
+ public static function permission(array $input): bool|WP_Error
+ {
+ $tax = (string) ($input['taxonomy'] ?? '');
+ if (!in_array($tax, self::ALLOWED_TAXONOMIES, true)) {
+ return new WP_Error('invalid_taxonomy', __('Taxonomy not allowed.', 'spoko-enhanced-rest-api'), ['status' => 400]);
+ }
+
+ $cap = $tax === 'category' ? 'manage_categories' : 'manage_post_tags';
+
+ return current_user_can($cap)
+ ? true
+ : new WP_Error('forbidden', __('You cannot manage this taxonomy.', 'spoko-enhanced-rest-api'), ['status' => 403]);
+ }
+
+ public static function create(array $input): array|WP_Error
+ {
+ $args = [];
+ foreach (['slug', 'description'] as $f) {
+ if (!empty($input[$f])) {
+ $args[$f] = (string) $input[$f];
+ }
+ }
+ if (!empty($input['parent_id'])) {
+ $args['parent'] = (int) $input['parent_id'];
+ }
+
+ $result = wp_insert_term((string) $input['name'], (string) $input['taxonomy'], $args);
+ if (is_wp_error($result)) {
+ return $result;
+ }
+
+ return ['id' => (int) $result['term_id']];
+ }
+
+ public static function update(array $input): array|WP_Error
+ {
+ $id = (int) $input['id'];
+ $tax = (string) $input['taxonomy'];
+
+ if (!term_exists($id, $tax)) {
+ return new WP_Error('not_found', __('Term does not exist in this taxonomy.', 'spoko-enhanced-rest-api'), ['status' => 404]);
+ }
+
+ $args = [];
+ foreach (['name', 'slug', 'description'] as $f) {
+ if (array_key_exists($f, $input)) {
+ $args[$f] = (string) $input[$f];
+ }
+ }
+ if (array_key_exists('parent_id', $input)) {
+ $args['parent'] = (int) $input['parent_id'];
+ }
+ if (empty($args)) {
+ return new WP_Error('no_updates', __('No fields provided to update.', 'spoko-enhanced-rest-api'), ['status' => 400]);
+ }
+
+ $result = wp_update_term($id, $tax, $args);
+ if (is_wp_error($result)) {
+ return $result;
+ }
+
+ return ['id' => (int) $result['term_id']];
+ }
+
+ public static function delete(array $input): array|WP_Error
+ {
+ $id = (int) $input['id'];
+ $tax = (string) $input['taxonomy'];
+
+ if (!term_exists($id, $tax)) {
+ return new WP_Error('not_found', __('Term does not exist in this taxonomy.', 'spoko-enhanced-rest-api'), ['status' => 404]);
+ }
+
+ $result = wp_delete_term($id, $tax);
+ if (is_wp_error($result)) {
+ return $result;
+ }
+ if ($result === false || $result === 0) {
+ return new WP_Error('delete_failed', __('Could not delete term.', 'spoko-enhanced-rest-api'));
+ }
+
+ return ['id' => $id];
+ }
+
+ private static function createInputSchema(): array
+ {
+ return [
+ 'type' => 'object',
+ 'required' => ['taxonomy', 'name'],
+ 'properties' => [
+ 'taxonomy' => ['type' => 'string', 'enum' => self::ALLOWED_TAXONOMIES],
+ 'name' => ['type' => 'string', 'minLength' => 1],
+ 'slug' => ['type' => 'string'],
+ 'description' => ['type' => 'string'],
+ 'parent_id' => ['type' => 'integer', 'minimum' => 0, 'description' => 'Only applies to categories.'],
+ ],
+ ];
+ }
+
+ private static function updateInputSchema(): array
+ {
+ return [
+ 'type' => 'object',
+ 'required' => ['taxonomy', 'id'],
+ 'properties' => [
+ 'taxonomy' => ['type' => 'string', 'enum' => self::ALLOWED_TAXONOMIES],
+ 'id' => ['type' => 'integer', 'minimum' => 1],
+ 'name' => ['type' => 'string', 'minLength' => 1],
+ 'slug' => ['type' => 'string'],
+ 'description' => ['type' => 'string'],
+ 'parent_id' => ['type' => 'integer', 'minimum' => 0],
+ ],
+ ];
+ }
+
+ private static function deleteInputSchema(): array
+ {
+ return [
+ 'type' => 'object',
+ 'required' => ['taxonomy', 'id'],
+ 'properties' => [
+ 'taxonomy' => ['type' => 'string', 'enum' => self::ALLOWED_TAXONOMIES],
+ 'id' => ['type' => 'integer', 'minimum' => 1],
+ ],
+ ];
+ }
+
+ private static function idSchema(): array
+ {
+ return [
+ 'type' => 'object',
+ 'properties' => [
+ 'id' => ['type' => 'integer'],
+ ],
+ ];
+ }
+}
diff --git a/src/Features/Mcp/Translations.php b/src/Features/Mcp/Translations.php
new file mode 100644
index 0000000..5156a35
--- /dev/null
+++ b/src/Features/Mcp/Translations.php
@@ -0,0 +1,154 @@
+ __('Link Polylang translations', 'spoko-enhanced-rest-api'),
+ 'description' => __('Add posts to a Polylang translation group. Each post is assigned its specified language code and merged into the existing translation group of any input post that is already linked. Existing translations in other languages are preserved.', 'spoko-enhanced-rest-api'),
+ 'category' => 'spoko-content',
+ 'input_schema' => [
+ 'type' => 'object',
+ 'required' => ['translations'],
+ 'properties' => [
+ 'translations' => [
+ 'type' => 'object',
+ 'description' => 'Map of language code (e.g. "en", "pl") to post ID. At least two entries required. Language codes must be registered in Polylang.',
+ 'minProperties' => 2,
+ 'additionalProperties' => ['type' => 'integer', 'minimum' => 1],
+ ],
+ ],
+ ],
+ 'output_schema' => [
+ 'type' => 'object',
+ 'properties' => [
+ 'translations' => ['type' => 'object'],
+ ],
+ ],
+ 'permission_callback' => static function ($input) {
+ if (!current_user_can('edit_posts')) {
+ return new WP_Error('forbidden', __('You cannot edit posts.', 'spoko-enhanced-rest-api'), ['status' => 403]);
+ }
+ foreach (($input['translations'] ?? []) as $postId) {
+ if (!current_user_can('edit_post', (int) $postId)) {
+ return new WP_Error(
+ 'forbidden',
+ sprintf(__('You cannot edit post %d.', 'spoko-enhanced-rest-api'), (int) $postId),
+ ['status' => 403]
+ );
+ }
+ }
+
+ return true;
+ },
+ 'execute_callback' => [self::class, 'link'],
+ 'meta' => ['annotations' => ['readonly' => false, 'destructive' => false, 'idempotent' => true]],
+ ]);
+
+ wp_register_ability('spoko/translations-unlink', [
+ 'label' => __('Unlink Polylang translation', 'spoko-enhanced-rest-api'),
+ 'description' => __('Remove a post from its translation group while keeping its language assignment.', 'spoko-enhanced-rest-api'),
+ 'category' => 'spoko-content',
+ 'input_schema' => [
+ 'type' => 'object',
+ 'required' => ['id'],
+ 'properties' => [
+ 'id' => ['type' => 'integer', 'minimum' => 1],
+ ],
+ ],
+ 'output_schema' => [
+ 'type' => 'object',
+ 'properties' => [
+ 'id' => ['type' => 'integer'],
+ ],
+ ],
+ 'permission_callback' => static fn ($input) => current_user_can('edit_post', (int) ($input['id'] ?? 0))
+ ? true
+ : new WP_Error('forbidden', __('You cannot edit this post.', 'spoko-enhanced-rest-api'), ['status' => 403]),
+ 'execute_callback' => [self::class, 'unlink'],
+ 'meta' => ['annotations' => ['readonly' => false, 'destructive' => true, 'idempotent' => true]],
+ ]);
+ }
+
+ public static function link(array $input): array|WP_Error
+ {
+ if (
+ !function_exists('pll_set_post_language')
+ || !function_exists('pll_save_post_translations')
+ || !function_exists('pll_languages_list')
+ || !function_exists('pll_get_post_translations')
+ ) {
+ return new WP_Error('no_polylang', __('Polylang is not active.', 'spoko-enhanced-rest-api'));
+ }
+
+ $inputMap = [];
+ foreach (($input['translations'] ?? []) as $lang => $postId) {
+ $inputMap[(string) $lang] = (int) $postId;
+ }
+
+ $registeredLanguages = pll_languages_list();
+ foreach (array_keys($inputMap) as $lang) {
+ if (!in_array($lang, $registeredLanguages, true)) {
+ return new WP_Error(
+ 'unknown_language',
+ sprintf(__('Language "%s" is not registered in Polylang.', 'spoko-enhanced-rest-api'), $lang),
+ ['status' => 400, 'registered' => $registeredLanguages]
+ );
+ }
+ }
+
+ $distinctGroups = [];
+ foreach ($inputMap as $postId) {
+ $existing = pll_get_post_translations($postId);
+ if (empty($existing)) {
+ continue;
+ }
+ ksort($existing);
+ $fingerprint = wp_json_encode($existing);
+ if ($fingerprint !== false) {
+ $distinctGroups[$fingerprint] = $existing;
+ }
+ }
+ if (count($distinctGroups) > 1) {
+ return new WP_Error(
+ 'multiple_translation_groups',
+ __('Input posts belong to more than one existing translation group. Unlink one side first to avoid silently fusing the groups.', 'spoko-enhanced-rest-api'),
+ ['status' => 409, 'groups' => array_values($distinctGroups)]
+ );
+ }
+
+ $merged = $distinctGroups ? reset($distinctGroups) : [];
+ foreach ($inputMap as $lang => $postId) {
+ pll_set_post_language($postId, $lang);
+ $merged[$lang] = $postId;
+ }
+
+ pll_save_post_translations($merged);
+
+ return ['translations' => $merged];
+ }
+
+ public static function unlink(array $input): array|WP_Error
+ {
+ if (!function_exists('pll_save_post_translations') || !function_exists('pll_get_post_language')) {
+ return new WP_Error('no_polylang', __('Polylang is not active.', 'spoko-enhanced-rest-api'));
+ }
+
+ $postId = (int) $input['id'];
+ $lang = pll_get_post_language($postId);
+ if (!$lang) {
+ return new WP_Error('no_language', __('Post has no language assigned.', 'spoko-enhanced-rest-api'));
+ }
+
+ pll_save_post_translations([(string) $lang => $postId]);
+
+ return ['id' => $postId];
+ }
+}
From ac5080b676a2efc2e1becf53d1183ca71d3a0b86 Mon Sep 17 00:00:00 2001
From: Szymon
Date: Mon, 18 May 2026 11:30:24 +0200
Subject: [PATCH 2/9] fix(mcp): correct tag capability and reject duplicate
translation post 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.
---
src/Features/Mcp/Terms.php | 2 +-
src/Features/Mcp/Translations.php | 8 ++++++++
2 files changed, 9 insertions(+), 1 deletion(-)
diff --git a/src/Features/Mcp/Terms.php b/src/Features/Mcp/Terms.php
index 78ea480..dcdb3c0 100644
--- a/src/Features/Mcp/Terms.php
+++ b/src/Features/Mcp/Terms.php
@@ -53,7 +53,7 @@ public static function permission(array $input): bool|WP_Error
return new WP_Error('invalid_taxonomy', __('Taxonomy not allowed.', 'spoko-enhanced-rest-api'), ['status' => 400]);
}
- $cap = $tax === 'category' ? 'manage_categories' : 'manage_post_tags';
+ $cap = get_taxonomy($tax)->cap->manage_terms;
return current_user_can($cap)
? true
diff --git a/src/Features/Mcp/Translations.php b/src/Features/Mcp/Translations.php
index 5156a35..c33378f 100644
--- a/src/Features/Mcp/Translations.php
+++ b/src/Features/Mcp/Translations.php
@@ -93,6 +93,14 @@ public static function link(array $input): array|WP_Error
$inputMap[(string) $lang] = (int) $postId;
}
+ if (count(array_unique($inputMap)) !== count($inputMap)) {
+ return new WP_Error(
+ 'duplicate_post_ids',
+ __('The same post ID cannot be linked to more than one language.', 'spoko-enhanced-rest-api'),
+ ['status' => 400, 'input' => $inputMap]
+ );
+ }
+
$registeredLanguages = pll_languages_list();
foreach (array_keys($inputMap) as $lang) {
if (!in_array($lang, $registeredLanguages, true)) {
From a325c717efed3e9ae6039cc120d49741ef2270ee Mon Sep 17 00:00:00 2001
From: Szymon
Date: Mon, 18 May 2026 13:15:41 +0200
Subject: [PATCH 3/9] fix(mcp): reject no-op post/page updates
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
src/Features/Mcp/Pages.php | 4 ++++
src/Features/Mcp/Posts.php | 21 +++++++++++++++------
2 files changed, 19 insertions(+), 6 deletions(-)
diff --git a/src/Features/Mcp/Pages.php b/src/Features/Mcp/Pages.php
index c4bb6bc..dd43cc0 100644
--- a/src/Features/Mcp/Pages.php
+++ b/src/Features/Mcp/Pages.php
@@ -132,6 +132,10 @@ public static function update(array $input): array|WP_Error
$args['post_parent'] = (int) $input['parent_id'];
}
+ if (count($args) === 1) {
+ return new WP_Error('no_updates', __('No fields provided to update.', 'spoko-enhanced-rest-api'), ['status' => 400]);
+ }
+
$id = wp_update_post($args, true);
if (is_wp_error($id)) {
return $id;
diff --git a/src/Features/Mcp/Posts.php b/src/Features/Mcp/Posts.php
index 3392b95..f9b2f0c 100644
--- a/src/Features/Mcp/Posts.php
+++ b/src/Features/Mcp/Posts.php
@@ -137,16 +137,25 @@ public static function update(array $input): array|WP_Error
$args['post_category'] = array_map('intval', $input['category_ids']);
}
- $id = wp_update_post($args, true);
- if (is_wp_error($id)) {
- return $id;
+ $hasTagUpdate = array_key_exists('tag_ids', $input);
+ if (count($args) === 1 && !$hasTagUpdate) {
+ return new WP_Error('no_updates', __('No fields provided to update.', 'spoko-enhanced-rest-api'), ['status' => 400]);
}
- if (array_key_exists('tag_ids', $input)) {
- wp_set_post_tags((int) $input['id'], array_map('intval', $input['tag_ids']));
+ $id = (int) $input['id'];
+ if (count($args) > 1) {
+ $result = wp_update_post($args, true);
+ if (is_wp_error($result)) {
+ return $result;
+ }
+ $id = (int) $result;
}
- return ['id' => (int) $id];
+ if ($hasTagUpdate) {
+ wp_set_post_tags($id, array_map('intval', $input['tag_ids']));
+ }
+
+ return ['id' => $id];
}
public static function delete(array $input): array|WP_Error
From c59afcffe0b1e0f4ef0d01aa153c2622a297e5f6 Mon Sep 17 00:00:00 2001
From: Szymon
Date: Mon, 18 May 2026 13:38:55 +0200
Subject: [PATCH 4/9] fix(mcp): surface term errors, idempotent delete,
translation slot conflicts
- 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.
---
src/Features/Mcp/Pages.php | 11 +++++-
src/Features/Mcp/Posts.php | 63 +++++++++++++++++++++++++++----
src/Features/Mcp/Translations.php | 14 +++++++
3 files changed, 78 insertions(+), 10 deletions(-)
diff --git a/src/Features/Mcp/Pages.php b/src/Features/Mcp/Pages.php
index dd43cc0..f2b02e1 100644
--- a/src/Features/Mcp/Pages.php
+++ b/src/Features/Mcp/Pages.php
@@ -146,12 +146,19 @@ public static function update(array $input): array|WP_Error
public static function delete(array $input): array|WP_Error
{
- $result = wp_delete_post((int) $input['id'], (bool) ($input['force'] ?? false));
+ $id = (int) $input['id'];
+ $force = (bool) ($input['force'] ?? false);
+
+ if (!$force && get_post_status($id) === 'trash') {
+ return ['id' => $id];
+ }
+
+ $result = wp_delete_post($id, $force);
if (!$result) {
return new WP_Error('delete_failed', __('Could not delete page.', 'spoko-enhanced-rest-api'));
}
- return ['id' => (int) $input['id']];
+ return ['id' => $id];
}
private static function createInputSchema(): array
diff --git a/src/Features/Mcp/Posts.php b/src/Features/Mcp/Posts.php
index f9b2f0c..7a92f42 100644
--- a/src/Features/Mcp/Posts.php
+++ b/src/Features/Mcp/Posts.php
@@ -89,6 +89,16 @@ public static function canDelete(array $input): bool|WP_Error
public static function create(array $input): array|WP_Error
{
+ $catIds = !empty($input['category_ids']) ? array_map('intval', $input['category_ids']) : [];
+ $tagIds = !empty($input['tag_ids']) ? array_map('intval', $input['tag_ids']) : [];
+
+ if ($error = self::validateTermIds($catIds, 'category')) {
+ return $error;
+ }
+ if ($error = self::validateTermIds($tagIds, 'post_tag')) {
+ return $error;
+ }
+
$args = [
'post_type' => 'post',
'post_title' => (string) $input['title'],
@@ -100,8 +110,8 @@ public static function create(array $input): array|WP_Error
$args[$field] = (string) $input[$in];
}
}
- if (!empty($input['category_ids'])) {
- $args['post_category'] = array_map('intval', $input['category_ids']);
+ if ($catIds) {
+ $args['post_category'] = $catIds;
}
$id = wp_insert_post($args, true);
@@ -109,8 +119,8 @@ public static function create(array $input): array|WP_Error
return $id;
}
- if (!empty($input['tag_ids'])) {
- wp_set_post_tags($id, array_map('intval', $input['tag_ids']));
+ if ($tagIds) {
+ wp_set_post_tags($id, $tagIds);
}
return ['id' => (int) $id];
@@ -134,10 +144,21 @@ public static function update(array $input): array|WP_Error
}
if (array_key_exists('category_ids', $input)) {
- $args['post_category'] = array_map('intval', $input['category_ids']);
+ $catIds = array_map('intval', $input['category_ids']);
+ if ($error = self::validateTermIds($catIds, 'category')) {
+ return $error;
+ }
+ $args['post_category'] = $catIds;
}
$hasTagUpdate = array_key_exists('tag_ids', $input);
+ if ($hasTagUpdate) {
+ $tagIds = array_map('intval', $input['tag_ids']);
+ if ($error = self::validateTermIds($tagIds, 'post_tag')) {
+ return $error;
+ }
+ }
+
if (count($args) === 1 && !$hasTagUpdate) {
return new WP_Error('no_updates', __('No fields provided to update.', 'spoko-enhanced-rest-api'), ['status' => 400]);
}
@@ -152,7 +173,7 @@ public static function update(array $input): array|WP_Error
}
if ($hasTagUpdate) {
- wp_set_post_tags($id, array_map('intval', $input['tag_ids']));
+ wp_set_post_tags($id, $tagIds);
}
return ['id' => $id];
@@ -160,12 +181,38 @@ public static function update(array $input): array|WP_Error
public static function delete(array $input): array|WP_Error
{
- $result = wp_delete_post((int) $input['id'], (bool) ($input['force'] ?? false));
+ $id = (int) $input['id'];
+ $force = (bool) ($input['force'] ?? false);
+
+ if (!$force && get_post_status($id) === 'trash') {
+ return ['id' => $id];
+ }
+
+ $result = wp_delete_post($id, $force);
if (!$result) {
return new WP_Error('delete_failed', __('Could not delete post.', 'spoko-enhanced-rest-api'));
}
- return ['id' => (int) $input['id']];
+ return ['id' => $id];
+ }
+
+ private static function validateTermIds(array $ids, string $taxonomy): ?WP_Error
+ {
+ foreach ($ids as $id) {
+ if (!term_exists((int) $id, $taxonomy)) {
+ return new WP_Error(
+ 'invalid_term_id',
+ sprintf(
+ __('Term %1$d does not exist in taxonomy "%2$s".', 'spoko-enhanced-rest-api'),
+ (int) $id,
+ $taxonomy
+ ),
+ ['status' => 400]
+ );
+ }
+ }
+
+ return null;
}
private static function createInputSchema(): array
diff --git a/src/Features/Mcp/Translations.php b/src/Features/Mcp/Translations.php
index c33378f..9581e52 100644
--- a/src/Features/Mcp/Translations.php
+++ b/src/Features/Mcp/Translations.php
@@ -133,6 +133,20 @@ public static function link(array $input): array|WP_Error
}
$merged = $distinctGroups ? reset($distinctGroups) : [];
+ foreach ($inputMap as $lang => $postId) {
+ if (isset($merged[$lang]) && (int) $merged[$lang] !== $postId) {
+ return new WP_Error(
+ 'language_slot_taken',
+ sprintf(
+ __('Language "%1$s" is already linked to post %2$d in the existing translation group. Unlink it first or omit it from this request.', 'spoko-enhanced-rest-api'),
+ $lang,
+ (int) $merged[$lang]
+ ),
+ ['status' => 409, 'existing' => $merged, 'incoming' => $inputMap]
+ );
+ }
+ }
+
foreach ($inputMap as $lang => $postId) {
pll_set_post_language($postId, $lang);
$merged[$lang] = $postId;
From a468741ab809ab45f608f1ac39e272abccec710c Mon Sep 17 00:00:00 2001
From: Szymon
Date: Mon, 18 May 2026 15:33:09 +0200
Subject: [PATCH 5/9] fix(mcp): translation post-already-linked check; page
parent validation
- 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).
---
src/Features/Mcp/Pages.php | 25 +++++++++++++++++++++++--
src/Features/Mcp/Translations.php | 19 ++++++++++++++++---
2 files changed, 39 insertions(+), 5 deletions(-)
diff --git a/src/Features/Mcp/Pages.php b/src/Features/Mcp/Pages.php
index f2b02e1..ad888be 100644
--- a/src/Features/Mcp/Pages.php
+++ b/src/Features/Mcp/Pages.php
@@ -101,7 +101,11 @@ public static function create(array $input): array|WP_Error
}
}
if (!empty($input['parent_id'])) {
- $args['post_parent'] = (int) $input['parent_id'];
+ $parentId = (int) $input['parent_id'];
+ if ($error = self::validateParent($parentId)) {
+ return $error;
+ }
+ $args['post_parent'] = $parentId;
}
$id = wp_insert_post($args, true);
@@ -129,7 +133,11 @@ public static function update(array $input): array|WP_Error
}
}
if (array_key_exists('parent_id', $input)) {
- $args['post_parent'] = (int) $input['parent_id'];
+ $parentId = (int) $input['parent_id'];
+ if ($parentId !== 0 && ($error = self::validateParent($parentId))) {
+ return $error;
+ }
+ $args['post_parent'] = $parentId;
}
if (count($args) === 1) {
@@ -161,6 +169,19 @@ public static function delete(array $input): array|WP_Error
return ['id' => $id];
}
+ private static function validateParent(int $parentId): ?WP_Error
+ {
+ if (get_post_type($parentId) !== 'page') {
+ return new WP_Error(
+ 'invalid_parent',
+ sprintf(__('Parent %d is not a page.', 'spoko-enhanced-rest-api'), $parentId),
+ ['status' => 400]
+ );
+ }
+
+ return null;
+ }
+
private static function createInputSchema(): array
{
return [
diff --git a/src/Features/Mcp/Translations.php b/src/Features/Mcp/Translations.php
index 9581e52..4ef63c9 100644
--- a/src/Features/Mcp/Translations.php
+++ b/src/Features/Mcp/Translations.php
@@ -132,15 +132,28 @@ public static function link(array $input): array|WP_Error
);
}
- $merged = $distinctGroups ? reset($distinctGroups) : [];
+ $merged = $distinctGroups ? array_map('intval', reset($distinctGroups)) : [];
foreach ($inputMap as $lang => $postId) {
- if (isset($merged[$lang]) && (int) $merged[$lang] !== $postId) {
+ if (isset($merged[$lang]) && $merged[$lang] !== $postId) {
return new WP_Error(
'language_slot_taken',
sprintf(
__('Language "%1$s" is already linked to post %2$d in the existing translation group. Unlink it first or omit it from this request.', 'spoko-enhanced-rest-api'),
$lang,
- (int) $merged[$lang]
+ $merged[$lang]
+ ),
+ ['status' => 409, 'existing' => $merged, 'incoming' => $inputMap]
+ );
+ }
+ $existingLang = array_search($postId, $merged, true);
+ if ($existingLang !== false && $existingLang !== $lang) {
+ return new WP_Error(
+ 'post_already_linked',
+ sprintf(
+ __('Post %1$d is already linked to language "%2$s" in the existing translation group; relinking to "%3$s" would duplicate it. Unlink the post first.', 'spoko-enhanced-rest-api'),
+ $postId,
+ (string) $existingLang,
+ $lang
),
['status' => 409, 'existing' => $merged, 'incoming' => $inputMap]
);
From 46c52b78b4ae9c99472b7f8b1b74a3052412cc8b Mon Sep 17 00:00:00 2001
From: Szymon
Date: Mon, 18 May 2026 15:51:44 +0200
Subject: [PATCH 6/9] feat(mcp): read abilities (posts-get, pages-get,
posts-list) + Rank Math 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.
---
src/Features/Mcp/AbilitiesFeature.php | 3 +-
src/Features/Mcp/Read.php | 253 ++++++++++++++++++++++++++
src/Features/Mcp/SeoMeta.php | 135 ++++++++++++++
3 files changed, 390 insertions(+), 1 deletion(-)
create mode 100644 src/Features/Mcp/Read.php
create mode 100644 src/Features/Mcp/SeoMeta.php
diff --git a/src/Features/Mcp/AbilitiesFeature.php b/src/Features/Mcp/AbilitiesFeature.php
index acbb7a9..2ccb712 100644
--- a/src/Features/Mcp/AbilitiesFeature.php
+++ b/src/Features/Mcp/AbilitiesFeature.php
@@ -33,7 +33,7 @@ public function registerCategory(): void
'spoko-content',
[
'label' => __('SPOKO content workflows', 'spoko-enhanced-rest-api'),
- 'description' => __('Headless content management: posts/pages/terms CRUD and Polylang translation pairing.', 'spoko-enhanced-rest-api'),
+ 'description' => __('Headless content management: read/list/CRUD for posts/pages/terms, Rank Math SEO, featured images, and Polylang translation pairing.', 'spoko-enhanced-rest-api'),
]
);
}
@@ -44,6 +44,7 @@ public function registerAbilities(): void
return;
}
+ Read::register();
Posts::register();
Pages::register();
Terms::register();
diff --git a/src/Features/Mcp/Read.php b/src/Features/Mcp/Read.php
new file mode 100644
index 0000000..046cc74
--- /dev/null
+++ b/src/Features/Mcp/Read.php
@@ -0,0 +1,253 @@
+ __('Get post', 'spoko-enhanced-rest-api'),
+ 'description' => __('Fetch a single post with title, content, excerpt, taxonomies, featured image, and Rank Math SEO fields — everything needed to recommend excerpt or SEO copy.', 'spoko-enhanced-rest-api'),
+ 'category' => 'spoko-content',
+ 'input_schema' => self::idSchema(),
+ 'output_schema' => self::postOutputSchema(),
+ 'permission_callback' => [self::class, 'canRead'],
+ 'execute_callback' => [self::class, 'getPost'],
+ 'meta' => ['annotations' => ['readonly' => true, 'destructive' => false, 'idempotent' => true]],
+ ]);
+
+ wp_register_ability('spoko/pages-get', [
+ 'label' => __('Get page', 'spoko-enhanced-rest-api'),
+ 'description' => __('Fetch a single page with title, content, excerpt, parent, featured image, and Rank Math SEO fields.', 'spoko-enhanced-rest-api'),
+ 'category' => 'spoko-content',
+ 'input_schema' => self::idSchema(),
+ 'output_schema' => self::pageOutputSchema(),
+ 'permission_callback' => [self::class, 'canRead'],
+ 'execute_callback' => [self::class, 'getPage'],
+ 'meta' => ['annotations' => ['readonly' => true, 'destructive' => false, 'idempotent' => true]],
+ ]);
+
+ wp_register_ability('spoko/posts-list', [
+ 'label' => __('List posts', 'spoko-enhanced-rest-api'),
+ 'description' => __('Paginated listing of posts with optional search/status/category/tag filters. Returns id, title, excerpt, status, and modified date for browsing.', 'spoko-enhanced-rest-api'),
+ 'category' => 'spoko-content',
+ 'input_schema' => self::listInputSchema(),
+ 'output_schema' => self::listOutputSchema(),
+ 'permission_callback' => static fn () => current_user_can('edit_posts')
+ ? true
+ : new WP_Error('forbidden', __('You cannot list posts.', 'spoko-enhanced-rest-api'), ['status' => 403]),
+ 'execute_callback' => [self::class, 'listPosts'],
+ 'meta' => ['annotations' => ['readonly' => true, 'destructive' => false, 'idempotent' => true]],
+ ]);
+ }
+
+ public static function canRead(array $input): bool|WP_Error
+ {
+ $id = (int) ($input['id'] ?? 0);
+ if (!get_post($id)) {
+ return new WP_Error('not_found', __('Post does not exist.', 'spoko-enhanced-rest-api'), ['status' => 404]);
+ }
+
+ return current_user_can('read_post', $id)
+ ? true
+ : new WP_Error('forbidden', __('You cannot read this post.', 'spoko-enhanced-rest-api'), ['status' => 403]);
+ }
+
+ public static function getPost(array $input): array|WP_Error
+ {
+ $id = (int) $input['id'];
+ $post = get_post($id);
+ if (!$post || $post->post_type !== 'post') {
+ return new WP_Error('not_a_post', __('Target is not a post.', 'spoko-enhanced-rest-api'), ['status' => 404]);
+ }
+
+ return [
+ 'id' => $post->ID,
+ 'title' => $post->post_title,
+ 'content' => $post->post_content,
+ 'excerpt' => $post->post_excerpt,
+ 'slug' => $post->post_name,
+ 'status' => $post->post_status,
+ 'date' => $post->post_date_gmt,
+ 'modified' => $post->post_modified_gmt,
+ 'author_id' => (int) $post->post_author,
+ 'category_ids' => wp_get_post_categories($id, ['fields' => 'ids']),
+ 'tag_ids' => wp_get_post_tags($id, ['fields' => 'ids']),
+ 'featured_image_id' => (int) get_post_thumbnail_id($id) ?: null,
+ 'seo' => SeoMeta::read($id),
+ ];
+ }
+
+ public static function getPage(array $input): array|WP_Error
+ {
+ $id = (int) $input['id'];
+ $post = get_post($id);
+ if (!$post || $post->post_type !== 'page') {
+ return new WP_Error('not_a_page', __('Target is not a page.', 'spoko-enhanced-rest-api'), ['status' => 404]);
+ }
+
+ return [
+ 'id' => $post->ID,
+ 'title' => $post->post_title,
+ 'content' => $post->post_content,
+ 'excerpt' => $post->post_excerpt,
+ 'slug' => $post->post_name,
+ 'status' => $post->post_status,
+ 'date' => $post->post_date_gmt,
+ 'modified' => $post->post_modified_gmt,
+ 'author_id' => (int) $post->post_author,
+ 'parent_id' => (int) $post->post_parent,
+ 'featured_image_id' => (int) get_post_thumbnail_id($id) ?: null,
+ 'seo' => SeoMeta::read($id),
+ ];
+ }
+
+ public static function listPosts(array $input): array
+ {
+ $perPage = max(1, min(100, (int) ($input['per_page'] ?? 20)));
+ $page = max(1, (int) ($input['page'] ?? 1));
+
+ $args = [
+ 'post_type' => 'post',
+ 'post_status' => $input['status'] ?? ['publish', 'draft', 'pending', 'private', 'future'],
+ 'posts_per_page' => $perPage,
+ 'paged' => $page,
+ 'orderby' => 'modified',
+ 'order' => 'DESC',
+ 'no_found_rows' => false,
+ ];
+ if (!empty($input['search'])) {
+ $args['s'] = (string) $input['search'];
+ }
+ if (!empty($input['category_id'])) {
+ $args['cat'] = (int) $input['category_id'];
+ }
+ if (!empty($input['tag_id'])) {
+ $args['tag_id'] = (int) $input['tag_id'];
+ }
+
+ $query = new WP_Query($args);
+ $items = [];
+ foreach ($query->posts as $post) {
+ $items[] = [
+ 'id' => $post->ID,
+ 'title' => $post->post_title,
+ 'excerpt' => $post->post_excerpt,
+ 'status' => $post->post_status,
+ 'modified' => $post->post_modified_gmt,
+ ];
+ }
+
+ return [
+ 'items' => $items,
+ 'page' => $page,
+ 'per_page' => $perPage,
+ 'total' => (int) $query->found_posts,
+ 'total_pages' => (int) $query->max_num_pages,
+ ];
+ }
+
+ private static function idSchema(): array
+ {
+ return [
+ 'type' => 'object',
+ 'required' => ['id'],
+ 'properties' => [
+ 'id' => ['type' => 'integer', 'minimum' => 1],
+ ],
+ ];
+ }
+
+ private static function listInputSchema(): array
+ {
+ return [
+ 'type' => 'object',
+ 'properties' => [
+ 'search' => ['type' => 'string'],
+ 'status' => [
+ 'type' => 'array',
+ 'items' => ['type' => 'string', 'enum' => ['publish', 'draft', 'pending', 'private', 'future']],
+ ],
+ 'category_id' => ['type' => 'integer', 'minimum' => 1],
+ 'tag_id' => ['type' => 'integer', 'minimum' => 1],
+ 'per_page' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 100, 'default' => 20],
+ 'page' => ['type' => 'integer', 'minimum' => 1, 'default' => 1],
+ ],
+ ];
+ }
+
+ private static function postOutputSchema(): array
+ {
+ return [
+ 'type' => 'object',
+ 'properties' => [
+ 'id' => ['type' => 'integer'],
+ 'title' => ['type' => 'string'],
+ 'content' => ['type' => 'string'],
+ 'excerpt' => ['type' => 'string'],
+ 'slug' => ['type' => 'string'],
+ 'status' => ['type' => 'string'],
+ 'date' => ['type' => 'string'],
+ 'modified' => ['type' => 'string'],
+ 'author_id' => ['type' => 'integer'],
+ 'category_ids' => ['type' => 'array', 'items' => ['type' => 'integer']],
+ 'tag_ids' => ['type' => 'array', 'items' => ['type' => 'integer']],
+ 'featured_image_id' => ['type' => ['integer', 'null']],
+ 'seo' => ['type' => 'object'],
+ ],
+ ];
+ }
+
+ private static function pageOutputSchema(): array
+ {
+ return [
+ 'type' => 'object',
+ 'properties' => [
+ 'id' => ['type' => 'integer'],
+ 'title' => ['type' => 'string'],
+ 'content' => ['type' => 'string'],
+ 'excerpt' => ['type' => 'string'],
+ 'slug' => ['type' => 'string'],
+ 'status' => ['type' => 'string'],
+ 'date' => ['type' => 'string'],
+ 'modified' => ['type' => 'string'],
+ 'author_id' => ['type' => 'integer'],
+ 'parent_id' => ['type' => 'integer'],
+ 'featured_image_id' => ['type' => ['integer', 'null']],
+ 'seo' => ['type' => 'object'],
+ ],
+ ];
+ }
+
+ private static function listOutputSchema(): array
+ {
+ return [
+ 'type' => 'object',
+ 'properties' => [
+ 'items' => [
+ 'type' => 'array',
+ 'items' => [
+ 'type' => 'object',
+ 'properties' => [
+ 'id' => ['type' => 'integer'],
+ 'title' => ['type' => 'string'],
+ 'excerpt' => ['type' => 'string'],
+ 'status' => ['type' => 'string'],
+ 'modified' => ['type' => 'string'],
+ ],
+ ],
+ ],
+ 'page' => ['type' => 'integer'],
+ 'per_page' => ['type' => 'integer'],
+ 'total' => ['type' => 'integer'],
+ 'total_pages' => ['type' => 'integer'],
+ ],
+ ];
+ }
+}
diff --git a/src/Features/Mcp/SeoMeta.php b/src/Features/Mcp/SeoMeta.php
new file mode 100644
index 0000000..028095e
--- /dev/null
+++ b/src/Features/Mcp/SeoMeta.php
@@ -0,0 +1,135 @@
+ 'rank_math_title',
+ 'description' => 'rank_math_description',
+ 'focus_keyword' => 'rank_math_focus_keyword',
+ 'canonical_url' => 'rank_math_canonical_url',
+ 'robots' => 'rank_math_robots',
+ 'og_title' => 'rank_math_facebook_title',
+ 'og_description' => 'rank_math_facebook_description',
+ 'og_image_id' => 'rank_math_facebook_image_id',
+ ];
+
+ private const ROBOTS_VALUES = ['index', 'noindex', 'nofollow', 'noarchive', 'nosnippet', 'noimageindex'];
+
+ public static function isActive(): bool
+ {
+ return defined('RANK_MATH_VERSION') || class_exists('RankMath\\Helper');
+ }
+
+ public static function read(int $postId): array
+ {
+ $out = [];
+ foreach (self::META_KEYS as $apiKey => $metaKey) {
+ $value = get_post_meta($postId, $metaKey, true);
+ $out[$apiKey] = match ($apiKey) {
+ 'og_image_id' => $value === '' ? null : (int) $value,
+ 'robots' => is_array($value) ? array_values($value) : [],
+ default => (string) $value,
+ };
+ }
+
+ return $out;
+ }
+
+ public static function validate(array $seo): ?WP_Error
+ {
+ if (!self::isActive()) {
+ return new WP_Error(
+ 'rank_math_inactive',
+ __('Rank Math is not active; SEO fields cannot be written.', 'spoko-enhanced-rest-api'),
+ ['status' => 400]
+ );
+ }
+
+ foreach ($seo as $apiKey => $value) {
+ if (!isset(self::META_KEYS[$apiKey])) {
+ continue;
+ }
+
+ if ($apiKey === 'og_image_id' && $value !== null) {
+ $id = (int) $value;
+ if (get_post_type($id) !== 'attachment') {
+ return new WP_Error(
+ 'invalid_og_image',
+ sprintf(__('OG image attachment %d does not exist.', 'spoko-enhanced-rest-api'), $id),
+ ['status' => 400]
+ );
+ }
+ }
+
+ if ($apiKey === 'robots') {
+ $list = array_values(array_unique(array_filter(array_map('strval', (array) $value))));
+ foreach ($list as $directive) {
+ if (!in_array($directive, self::ROBOTS_VALUES, true)) {
+ return new WP_Error(
+ 'invalid_robots_directive',
+ sprintf(__('Robots directive "%s" is not supported.', 'spoko-enhanced-rest-api'), $directive),
+ ['status' => 400, 'allowed' => self::ROBOTS_VALUES]
+ );
+ }
+ }
+ }
+ }
+
+ return null;
+ }
+
+ public static function write(int $postId, array $seo): void
+ {
+ foreach ($seo as $apiKey => $value) {
+ if (!isset(self::META_KEYS[$apiKey])) {
+ continue;
+ }
+ $metaKey = self::META_KEYS[$apiKey];
+
+ if ($apiKey === 'og_image_id') {
+ if ($value === null) {
+ delete_post_meta($postId, $metaKey);
+ } else {
+ update_post_meta($postId, $metaKey, (int) $value);
+ }
+ continue;
+ }
+
+ if ($apiKey === 'robots') {
+ $list = array_values(array_unique(array_filter(array_map('strval', (array) $value))));
+ update_post_meta($postId, $metaKey, $list);
+ continue;
+ }
+
+ update_post_meta($postId, $metaKey, (string) $value);
+ }
+ }
+
+ public static function inputSchema(): array
+ {
+ return [
+ 'type' => 'object',
+ 'description' => 'Rank Math SEO fields. Only keys included are written; omitted keys are unchanged. Use og_image_id=null to clear the OG image.',
+ 'properties' => [
+ 'title' => ['type' => 'string', 'description' => 'Meta title (overrides post title in search/social).'],
+ 'description' => ['type' => 'string', 'description' => 'Meta description (search snippet).'],
+ 'focus_keyword' => ['type' => 'string', 'description' => 'Focus keyword; multiple values comma-separated.'],
+ 'canonical_url' => ['type' => 'string', 'description' => 'Canonical URL on the Astro frontend (polo.blue/...).'],
+ 'robots' => [
+ 'type' => 'array',
+ 'items' => ['type' => 'string', 'enum' => self::ROBOTS_VALUES],
+ ],
+ 'og_title' => ['type' => 'string'],
+ 'og_description' => ['type' => 'string'],
+ 'og_image_id' => ['type' => ['integer', 'null'], 'minimum' => 1],
+ ],
+ 'additionalProperties' => false,
+ ];
+ }
+}
From c5d6b953928ab7c752612695a1842a6c4f8d1df8 Mon Sep 17 00:00:00 2001
From: Szymon
Date: Mon, 18 May 2026 15:55:36 +0200
Subject: [PATCH 7/9] feat(mcp): seo and featured_image_id on posts/pages
create+update
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.
---
src/Features/Mcp/Pages.php | 104 ++++++++++++++++++++++++++++++-------
src/Features/Mcp/Posts.php | 96 ++++++++++++++++++++++++++++------
2 files changed, 166 insertions(+), 34 deletions(-)
diff --git a/src/Features/Mcp/Pages.php b/src/Features/Mcp/Pages.php
index ad888be..73a3af8 100644
--- a/src/Features/Mcp/Pages.php
+++ b/src/Features/Mcp/Pages.php
@@ -89,6 +89,19 @@ public static function canDelete(array $input): bool|WP_Error
public static function create(array $input): array|WP_Error
{
+ $featuredId = null;
+ if (!empty($input['featured_image_id'])) {
+ $featuredId = (int) $input['featured_image_id'];
+ if ($error = self::validateAttachment($featuredId)) {
+ return $error;
+ }
+ }
+
+ $seo = (!empty($input['seo']) && is_array($input['seo'])) ? $input['seo'] : [];
+ if ($seo && ($error = SeoMeta::validate($seo))) {
+ return $error;
+ }
+
$args = [
'post_type' => 'page',
'post_title' => (string) $input['title'],
@@ -113,6 +126,14 @@ public static function create(array $input): array|WP_Error
return $id;
}
+ if ($featuredId !== null) {
+ set_post_thumbnail($id, $featuredId);
+ }
+
+ if ($seo) {
+ SeoMeta::write($id, $seo);
+ }
+
return ['id' => (int) $id];
}
@@ -140,16 +161,46 @@ public static function update(array $input): array|WP_Error
$args['post_parent'] = $parentId;
}
- if (count($args) === 1) {
+ $hasFeaturedUpdate = array_key_exists('featured_image_id', $input);
+ $featuredId = null;
+ if ($hasFeaturedUpdate && $input['featured_image_id'] !== null) {
+ $featuredId = (int) $input['featured_image_id'];
+ if ($error = self::validateAttachment($featuredId)) {
+ return $error;
+ }
+ }
+
+ $seo = (!empty($input['seo']) && is_array($input['seo'])) ? $input['seo'] : [];
+ if ($seo && ($error = SeoMeta::validate($seo))) {
+ return $error;
+ }
+
+ if (count($args) === 1 && !$hasFeaturedUpdate && !$seo) {
return new WP_Error('no_updates', __('No fields provided to update.', 'spoko-enhanced-rest-api'), ['status' => 400]);
}
- $id = wp_update_post($args, true);
- if (is_wp_error($id)) {
- return $id;
+ $id = (int) $input['id'];
+ if (count($args) > 1) {
+ $result = wp_update_post($args, true);
+ if (is_wp_error($result)) {
+ return $result;
+ }
+ $id = (int) $result;
}
- return ['id' => (int) $id];
+ if ($hasFeaturedUpdate) {
+ if ($featuredId === null) {
+ delete_post_thumbnail($id);
+ } else {
+ set_post_thumbnail($id, $featuredId);
+ }
+ }
+
+ if ($seo) {
+ SeoMeta::write($id, $seo);
+ }
+
+ return ['id' => $id];
}
public static function delete(array $input): array|WP_Error
@@ -169,6 +220,19 @@ public static function delete(array $input): array|WP_Error
return ['id' => $id];
}
+ private static function validateAttachment(int $id): ?WP_Error
+ {
+ if (get_post_type($id) !== 'attachment') {
+ return new WP_Error(
+ 'invalid_attachment',
+ sprintf(__('Attachment %d does not exist.', 'spoko-enhanced-rest-api'), $id),
+ ['status' => 400]
+ );
+ }
+
+ return null;
+ }
+
private static function validateParent(int $parentId): ?WP_Error
{
if (get_post_type($parentId) !== 'page') {
@@ -188,12 +252,14 @@ private static function createInputSchema(): array
'type' => 'object',
'required' => ['title'],
'properties' => [
- 'title' => ['type' => 'string', 'minLength' => 1],
- 'content' => ['type' => 'string'],
- 'excerpt' => ['type' => 'string'],
- 'status' => ['type' => 'string', 'enum' => ['draft', 'publish', 'pending', 'private', 'future']],
- 'slug' => ['type' => 'string'],
- 'parent_id' => ['type' => 'integer', 'minimum' => 0],
+ 'title' => ['type' => 'string', 'minLength' => 1],
+ 'content' => ['type' => 'string'],
+ 'excerpt' => ['type' => 'string'],
+ 'status' => ['type' => 'string', 'enum' => ['draft', 'publish', 'pending', 'private', 'future']],
+ 'slug' => ['type' => 'string'],
+ 'parent_id' => ['type' => 'integer', 'minimum' => 0],
+ 'featured_image_id' => ['type' => 'integer', 'minimum' => 1],
+ 'seo' => SeoMeta::inputSchema(),
],
];
}
@@ -204,13 +270,15 @@ private static function updateInputSchema(): array
'type' => 'object',
'required' => ['id'],
'properties' => [
- 'id' => ['type' => 'integer', 'minimum' => 1],
- 'title' => ['type' => 'string', 'minLength' => 1],
- 'content' => ['type' => 'string'],
- 'excerpt' => ['type' => 'string'],
- 'status' => ['type' => 'string', 'enum' => ['draft', 'publish', 'pending', 'private', 'future']],
- 'slug' => ['type' => 'string'],
- 'parent_id' => ['type' => 'integer', 'minimum' => 0],
+ 'id' => ['type' => 'integer', 'minimum' => 1],
+ 'title' => ['type' => 'string', 'minLength' => 1],
+ 'content' => ['type' => 'string'],
+ 'excerpt' => ['type' => 'string'],
+ 'status' => ['type' => 'string', 'enum' => ['draft', 'publish', 'pending', 'private', 'future']],
+ 'slug' => ['type' => 'string'],
+ 'parent_id' => ['type' => 'integer', 'minimum' => 0],
+ 'featured_image_id' => ['type' => ['integer', 'null'], 'minimum' => 1, 'description' => 'Attachment ID; pass null to clear.'],
+ 'seo' => SeoMeta::inputSchema(),
],
];
}
diff --git a/src/Features/Mcp/Posts.php b/src/Features/Mcp/Posts.php
index 7a92f42..31e2356 100644
--- a/src/Features/Mcp/Posts.php
+++ b/src/Features/Mcp/Posts.php
@@ -99,6 +99,19 @@ public static function create(array $input): array|WP_Error
return $error;
}
+ $featuredId = null;
+ if (!empty($input['featured_image_id'])) {
+ $featuredId = (int) $input['featured_image_id'];
+ if ($error = self::validateAttachment($featuredId)) {
+ return $error;
+ }
+ }
+
+ $seo = (!empty($input['seo']) && is_array($input['seo'])) ? $input['seo'] : [];
+ if ($seo && ($error = SeoMeta::validate($seo))) {
+ return $error;
+ }
+
$args = [
'post_type' => 'post',
'post_title' => (string) $input['title'],
@@ -123,6 +136,14 @@ public static function create(array $input): array|WP_Error
wp_set_post_tags($id, $tagIds);
}
+ if ($featuredId !== null) {
+ set_post_thumbnail($id, $featuredId);
+ }
+
+ if ($seo) {
+ SeoMeta::write($id, $seo);
+ }
+
return ['id' => (int) $id];
}
@@ -159,7 +180,21 @@ public static function update(array $input): array|WP_Error
}
}
- if (count($args) === 1 && !$hasTagUpdate) {
+ $hasFeaturedUpdate = array_key_exists('featured_image_id', $input);
+ $featuredId = null;
+ if ($hasFeaturedUpdate && $input['featured_image_id'] !== null) {
+ $featuredId = (int) $input['featured_image_id'];
+ if ($error = self::validateAttachment($featuredId)) {
+ return $error;
+ }
+ }
+
+ $seo = (!empty($input['seo']) && is_array($input['seo'])) ? $input['seo'] : [];
+ if ($seo && ($error = SeoMeta::validate($seo))) {
+ return $error;
+ }
+
+ if (count($args) === 1 && !$hasTagUpdate && !$hasFeaturedUpdate && !$seo) {
return new WP_Error('no_updates', __('No fields provided to update.', 'spoko-enhanced-rest-api'), ['status' => 400]);
}
@@ -176,6 +211,18 @@ public static function update(array $input): array|WP_Error
wp_set_post_tags($id, $tagIds);
}
+ if ($hasFeaturedUpdate) {
+ if ($featuredId === null) {
+ delete_post_thumbnail($id);
+ } else {
+ set_post_thumbnail($id, $featuredId);
+ }
+ }
+
+ if ($seo) {
+ SeoMeta::write($id, $seo);
+ }
+
return ['id' => $id];
}
@@ -196,6 +243,19 @@ public static function delete(array $input): array|WP_Error
return ['id' => $id];
}
+ private static function validateAttachment(int $id): ?WP_Error
+ {
+ if (get_post_type($id) !== 'attachment') {
+ return new WP_Error(
+ 'invalid_attachment',
+ sprintf(__('Attachment %d does not exist.', 'spoko-enhanced-rest-api'), $id),
+ ['status' => 400]
+ );
+ }
+
+ return null;
+ }
+
private static function validateTermIds(array $ids, string $taxonomy): ?WP_Error
{
foreach ($ids as $id) {
@@ -221,13 +281,15 @@ private static function createInputSchema(): array
'type' => 'object',
'required' => ['title'],
'properties' => [
- 'title' => ['type' => 'string', 'minLength' => 1],
- 'content' => ['type' => 'string'],
- 'excerpt' => ['type' => 'string'],
- 'status' => ['type' => 'string', 'enum' => ['draft', 'publish', 'pending', 'private', 'future']],
- 'slug' => ['type' => 'string'],
- 'category_ids' => ['type' => 'array', 'items' => ['type' => 'integer']],
- 'tag_ids' => ['type' => 'array', 'items' => ['type' => 'integer']],
+ 'title' => ['type' => 'string', 'minLength' => 1],
+ 'content' => ['type' => 'string'],
+ 'excerpt' => ['type' => 'string'],
+ 'status' => ['type' => 'string', 'enum' => ['draft', 'publish', 'pending', 'private', 'future']],
+ 'slug' => ['type' => 'string'],
+ 'category_ids' => ['type' => 'array', 'items' => ['type' => 'integer']],
+ 'tag_ids' => ['type' => 'array', 'items' => ['type' => 'integer']],
+ 'featured_image_id' => ['type' => 'integer', 'minimum' => 1],
+ 'seo' => SeoMeta::inputSchema(),
],
];
}
@@ -238,14 +300,16 @@ private static function updateInputSchema(): array
'type' => 'object',
'required' => ['id'],
'properties' => [
- 'id' => ['type' => 'integer', 'minimum' => 1],
- 'title' => ['type' => 'string', 'minLength' => 1],
- 'content' => ['type' => 'string'],
- 'excerpt' => ['type' => 'string'],
- 'status' => ['type' => 'string', 'enum' => ['draft', 'publish', 'pending', 'private', 'future']],
- 'slug' => ['type' => 'string'],
- 'category_ids' => ['type' => 'array', 'items' => ['type' => 'integer']],
- 'tag_ids' => ['type' => 'array', 'items' => ['type' => 'integer']],
+ 'id' => ['type' => 'integer', 'minimum' => 1],
+ 'title' => ['type' => 'string', 'minLength' => 1],
+ 'content' => ['type' => 'string'],
+ 'excerpt' => ['type' => 'string'],
+ 'status' => ['type' => 'string', 'enum' => ['draft', 'publish', 'pending', 'private', 'future']],
+ 'slug' => ['type' => 'string'],
+ 'category_ids' => ['type' => 'array', 'items' => ['type' => 'integer']],
+ 'tag_ids' => ['type' => 'array', 'items' => ['type' => 'integer']],
+ 'featured_image_id' => ['type' => ['integer', 'null'], 'minimum' => 1, 'description' => 'Attachment ID; pass null to clear.'],
+ 'seo' => SeoMeta::inputSchema(),
],
];
}
From a90097fa728832949cffc25bfd0a38c0436d9ca0 Mon Sep 17 00:00:00 2001
From: Szymon
Date: Mon, 18 May 2026 17:20:25 +0200
Subject: [PATCH 8/9] feat(mcp): posts-translate ability + media translation
helper
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.
---
src/Features/Mcp/AbilitiesFeature.php | 1 +
src/Features/Mcp/MediaTranslator.php | 133 ++++++++++++++++
src/Features/Mcp/Translate.php | 217 ++++++++++++++++++++++++++
3 files changed, 351 insertions(+)
create mode 100644 src/Features/Mcp/MediaTranslator.php
create mode 100644 src/Features/Mcp/Translate.php
diff --git a/src/Features/Mcp/AbilitiesFeature.php b/src/Features/Mcp/AbilitiesFeature.php
index 2ccb712..739c35f 100644
--- a/src/Features/Mcp/AbilitiesFeature.php
+++ b/src/Features/Mcp/AbilitiesFeature.php
@@ -51,6 +51,7 @@ public function registerAbilities(): void
if (function_exists('pll_set_post_language') && function_exists('pll_save_post_translations')) {
Translations::register();
+ Translate::register();
}
}
}
diff --git a/src/Features/Mcp/MediaTranslator.php b/src/Features/Mcp/MediaTranslator.php
new file mode 100644
index 0000000..b60e451
--- /dev/null
+++ b/src/Features/Mcp/MediaTranslator.php
@@ -0,0 +1,133 @@
+post_type !== 'attachment') {
+ return $attachmentId;
+ }
+
+ $existing = (int) pll_get_post($attachmentId, $targetLang);
+ if ($existing > 0) {
+ return $existing;
+ }
+
+ if (!pll_get_post_language($attachmentId)) {
+ pll_set_post_language($attachmentId, $sourceLang);
+ }
+
+ $newId = wp_insert_post([
+ 'post_type' => 'attachment',
+ 'post_title' => $source->post_title,
+ 'post_content' => $source->post_content,
+ 'post_excerpt' => $source->post_excerpt,
+ 'post_mime_type' => $source->post_mime_type,
+ 'post_status' => 'inherit',
+ 'post_parent' => 0,
+ 'guid' => $source->guid,
+ ], true);
+
+ if (is_wp_error($newId) || !$newId) {
+ return $attachmentId;
+ }
+
+ foreach (self::ATTACHMENT_META_KEYS as $metaKey) {
+ $value = get_post_meta($attachmentId, $metaKey, true);
+ if ($value !== '' && $value !== false && $value !== null) {
+ update_post_meta($newId, $metaKey, $value);
+ }
+ }
+
+ pll_set_post_language($newId, $targetLang);
+
+ $group = pll_get_post_translations($attachmentId);
+ $group[$targetLang] = $newId;
+ pll_save_post_translations($group);
+
+ return $newId;
+ }
+
+ public static function rewriteContent(string $content, string $targetLang, string $sourceLang): array
+ {
+ if ($content === '' || !self::isMediaTranslated()) {
+ return ['content' => $content, 'translations' => []];
+ }
+
+ $translations = [];
+ $blocks = parse_blocks($content);
+ $newBlocks = self::walkBlocks($blocks, $targetLang, $sourceLang, $translations);
+ $newContent = serialize_blocks($newBlocks);
+
+ foreach ($translations as $oldId => $newId) {
+ if ($oldId !== $newId) {
+ $newContent = str_replace("wp-image-{$oldId}", "wp-image-{$newId}", $newContent);
+ }
+ }
+
+ return ['content' => $newContent, 'translations' => $translations];
+ }
+
+ private static function walkBlocks(array $blocks, string $targetLang, string $sourceLang, array &$translations): array
+ {
+ foreach ($blocks as &$block) {
+ $name = $block['blockName'] ?? null;
+ $attrs = $block['attrs'] ?? [];
+
+ if (in_array($name, ['core/image', 'core/cover', 'core/media-text'], true) && !empty($attrs['id'])) {
+ $attrs['id'] = self::resolve((int) $attrs['id'], $targetLang, $sourceLang, $translations);
+ }
+
+ if ($name === 'core/gallery' && !empty($attrs['ids']) && is_array($attrs['ids'])) {
+ $attrs['ids'] = array_map(
+ fn ($id) => self::resolve((int) $id, $targetLang, $sourceLang, $translations),
+ $attrs['ids']
+ );
+ }
+
+ $block['attrs'] = $attrs;
+
+ if (!empty($block['innerBlocks'])) {
+ $block['innerBlocks'] = self::walkBlocks($block['innerBlocks'], $targetLang, $sourceLang, $translations);
+ }
+ }
+
+ return $blocks;
+ }
+
+ private static function resolve(int $sourceId, string $targetLang, string $sourceLang, array &$translations): int
+ {
+ if ($sourceId <= 0) {
+ return $sourceId;
+ }
+ if (isset($translations[$sourceId])) {
+ return $translations[$sourceId];
+ }
+
+ $translated = self::ensureTranslation($sourceId, $targetLang, $sourceLang);
+ $translations[$sourceId] = $translated;
+
+ return $translated;
+ }
+}
diff --git a/src/Features/Mcp/Translate.php b/src/Features/Mcp/Translate.php
new file mode 100644
index 0000000..f1a8165
--- /dev/null
+++ b/src/Features/Mcp/Translate.php
@@ -0,0 +1,217 @@
+ __('Translate post', 'spoko-enhanced-rest-api'),
+ 'description' => __('Copy a post into a new language, link the pair via Polylang, and auto-translate referenced media (featured image + Gutenberg image/gallery/cover/media-text blocks) so the new post does not reference foreign-language attachments.', 'spoko-enhanced-rest-api'),
+ 'category' => 'spoko-content',
+ 'input_schema' => self::inputSchema(),
+ 'output_schema' => self::outputSchema(),
+ 'permission_callback' => [self::class, 'canTranslate'],
+ 'execute_callback' => [self::class, 'execute'],
+ 'meta' => ['annotations' => ['readonly' => false, 'destructive' => false, 'idempotent' => false]],
+ ]);
+ }
+
+ public static function canTranslate(array $input): bool|WP_Error
+ {
+ $sourceId = (int) ($input['source_id'] ?? 0);
+ if (get_post_type($sourceId) !== 'post') {
+ return new WP_Error('not_a_post', __('Source is not a post.', 'spoko-enhanced-rest-api'), ['status' => 404]);
+ }
+ if (!current_user_can('edit_post', $sourceId)) {
+ return new WP_Error('forbidden', __('You cannot read the source post.', 'spoko-enhanced-rest-api'), ['status' => 403]);
+ }
+ if (!current_user_can('edit_posts')) {
+ return new WP_Error('forbidden', __('You cannot create posts.', 'spoko-enhanced-rest-api'), ['status' => 403]);
+ }
+
+ return true;
+ }
+
+ public static function execute(array $input): array|WP_Error
+ {
+ if (
+ !function_exists('pll_set_post_language')
+ || !function_exists('pll_save_post_translations')
+ || !function_exists('pll_languages_list')
+ || !function_exists('pll_get_post_translations')
+ || !function_exists('pll_get_post_language')
+ ) {
+ return new WP_Error('no_polylang', __('Polylang is not active.', 'spoko-enhanced-rest-api'));
+ }
+
+ $sourceId = (int) $input['source_id'];
+ $targetLang = (string) $input['language'];
+ $overrides = (array) ($input['overrides'] ?? []);
+ $duplicateMedia = !array_key_exists('duplicate_media', $input) || (bool) $input['duplicate_media'];
+
+ $registeredLanguages = pll_languages_list();
+ if (!in_array($targetLang, $registeredLanguages, true)) {
+ return new WP_Error(
+ 'unknown_language',
+ sprintf(__('Language "%s" is not registered in Polylang.', 'spoko-enhanced-rest-api'), $targetLang),
+ ['status' => 400, 'registered' => $registeredLanguages]
+ );
+ }
+
+ $source = get_post($sourceId);
+ $sourceLang = (string) pll_get_post_language($sourceId);
+ if (!$sourceLang) {
+ return new WP_Error(
+ 'source_no_language',
+ __('Source post has no Polylang language assigned. Assign one before translating.', 'spoko-enhanced-rest-api'),
+ ['status' => 400]
+ );
+ }
+ if ($sourceLang === $targetLang) {
+ return new WP_Error(
+ 'same_language',
+ __('Source and target languages are identical.', 'spoko-enhanced-rest-api'),
+ ['status' => 400]
+ );
+ }
+
+ $existingTranslation = (int) (pll_get_post($sourceId, $targetLang) ?: 0);
+ if ($existingTranslation > 0) {
+ return new WP_Error(
+ 'translation_exists',
+ sprintf(__('Source post already has a "%s" translation (post %d). Use posts-update to modify it.', 'spoko-enhanced-rest-api'), $targetLang, $existingTranslation),
+ ['status' => 409, 'existing_id' => $existingTranslation]
+ );
+ }
+
+ $content = (string) ($overrides['content'] ?? $source->post_content);
+ $mediaTranslations = [];
+
+ if ($duplicateMedia && $content !== '') {
+ $rewrite = MediaTranslator::rewriteContent($content, $targetLang, $sourceLang);
+ $content = $rewrite['content'];
+ $mediaTranslations = $rewrite['translations'];
+ }
+
+ $args = [
+ 'post_type' => 'post',
+ 'post_title' => (string) ($overrides['title'] ?? $source->post_title),
+ 'post_content' => $content,
+ 'post_excerpt' => (string) ($overrides['excerpt'] ?? $source->post_excerpt),
+ 'post_status' => (string) ($overrides['status'] ?? 'draft'),
+ 'post_name' => (string) ($overrides['slug'] ?? $source->post_name),
+ ];
+
+ $newId = wp_insert_post($args, true);
+ if (is_wp_error($newId)) {
+ return $newId;
+ }
+
+ $featuredId = self::resolveFeaturedImage($input, $source->ID, $targetLang, $sourceLang, $duplicateMedia, $mediaTranslations);
+ if ($featuredId instanceof WP_Error) {
+ return $featuredId;
+ }
+ if ($featuredId !== null) {
+ set_post_thumbnail($newId, $featuredId);
+ }
+
+ pll_set_post_language($newId, $targetLang);
+ $group = pll_get_post_translations($sourceId);
+ $group[$sourceLang] = $sourceId;
+ $group[$targetLang] = $newId;
+ pll_save_post_translations($group);
+
+ $report = [];
+ foreach ($mediaTranslations as $oldId => $newAttId) {
+ $report[] = [
+ 'source' => (int) $oldId,
+ 'translation' => (int) $newAttId,
+ 'created' => (int) $oldId !== (int) $newAttId,
+ ];
+ }
+
+ return [
+ 'id' => (int) $newId,
+ 'media_translations' => $report,
+ ];
+ }
+
+ private static function resolveFeaturedImage(array $input, int $sourceId, string $targetLang, string $sourceLang, bool $duplicateMedia, array &$mediaTranslations): int|null|WP_Error
+ {
+ $overrides = (array) ($input['overrides'] ?? []);
+
+ if (array_key_exists('featured_image_id', $overrides)) {
+ return $overrides['featured_image_id'] === null ? null : (int) $overrides['featured_image_id'];
+ }
+
+ $sourceThumb = (int) get_post_thumbnail_id($sourceId);
+ if ($sourceThumb <= 0) {
+ return null;
+ }
+
+ if (!$duplicateMedia || !MediaTranslator::isMediaTranslated()) {
+ return $sourceThumb;
+ }
+
+ $translated = MediaTranslator::ensureTranslation($sourceThumb, $targetLang, $sourceLang);
+ $mediaTranslations[$sourceThumb] = $translated;
+
+ return $translated;
+ }
+
+ private static function inputSchema(): array
+ {
+ return [
+ 'type' => 'object',
+ 'required' => ['source_id', 'language'],
+ 'properties' => [
+ 'source_id' => ['type' => 'integer', 'minimum' => 1, 'description' => 'Post ID to translate from.'],
+ 'language' => ['type' => 'string', 'description' => 'Polylang language code of the new post (e.g. "en", "pl").'],
+ 'overrides' => [
+ 'type' => 'object',
+ 'description' => 'Optional overrides applied on top of the copied source fields.',
+ 'properties' => [
+ 'title' => ['type' => 'string'],
+ 'content' => ['type' => 'string', 'description' => 'If provided, replaces source content and is itself walked for embedded media when duplicate_media is true.'],
+ 'excerpt' => ['type' => 'string'],
+ 'slug' => ['type' => 'string'],
+ 'status' => ['type' => 'string', 'enum' => ['draft', 'publish', 'pending', 'private', 'future']],
+ 'featured_image_id' => ['type' => ['integer', 'null'], 'minimum' => 1],
+ ],
+ ],
+ 'duplicate_media' => [
+ 'type' => 'boolean',
+ 'default' => true,
+ 'description' => 'When true and Polylang media translations are enabled, walk the content body and featured image, creating language-tagged attachment copies that share the underlying file. No-op when Polylang media translation is disabled.',
+ ],
+ ],
+ ];
+ }
+
+ private static function outputSchema(): array
+ {
+ return [
+ 'type' => 'object',
+ 'properties' => [
+ 'id' => ['type' => 'integer'],
+ 'media_translations' => [
+ 'type' => 'array',
+ 'items' => [
+ 'type' => 'object',
+ 'properties' => [
+ 'source' => ['type' => 'integer'],
+ 'translation' => ['type' => 'integer'],
+ 'created' => ['type' => 'boolean'],
+ ],
+ ],
+ ],
+ ],
+ ];
+ }
+}
From 289483d7b41404b83f5c7931c836845223bdc522 Mon Sep 17 00:00:00 2001
From: Szymon
Date: Tue, 19 May 2026 14:52:04 +0200
Subject: [PATCH 9/9] feat(mcp): attachments-rewire ability for backfilling
existing posts
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.
---
src/Features/Mcp/AbilitiesFeature.php | 1 +
src/Features/Mcp/MediaTranslator.php | 146 ++++++++++++++++++--------
src/Features/Mcp/Rewire.php | 141 +++++++++++++++++++++++++
3 files changed, 247 insertions(+), 41 deletions(-)
create mode 100644 src/Features/Mcp/Rewire.php
diff --git a/src/Features/Mcp/AbilitiesFeature.php b/src/Features/Mcp/AbilitiesFeature.php
index 739c35f..4b4ba7b 100644
--- a/src/Features/Mcp/AbilitiesFeature.php
+++ b/src/Features/Mcp/AbilitiesFeature.php
@@ -52,6 +52,7 @@ public function registerAbilities(): void
if (function_exists('pll_set_post_language') && function_exists('pll_save_post_translations')) {
Translations::register();
Translate::register();
+ Rewire::register();
}
}
}
diff --git a/src/Features/Mcp/MediaTranslator.php b/src/Features/Mcp/MediaTranslator.php
index b60e451..ba030e6 100644
--- a/src/Features/Mcp/MediaTranslator.php
+++ b/src/Features/Mcp/MediaTranslator.php
@@ -4,6 +4,8 @@
namespace Spoko\EnhancedRestAPI\Features\Mcp;
+use WP_Post;
+
final class MediaTranslator
{
private const ATTACHMENT_META_KEYS = [
@@ -38,35 +40,37 @@ public static function ensureTranslation(int $attachmentId, string $targetLang,
pll_set_post_language($attachmentId, $sourceLang);
}
- $newId = wp_insert_post([
- 'post_type' => 'attachment',
- 'post_title' => $source->post_title,
- 'post_content' => $source->post_content,
- 'post_excerpt' => $source->post_excerpt,
- 'post_mime_type' => $source->post_mime_type,
- 'post_status' => 'inherit',
- 'post_parent' => 0,
- 'guid' => $source->guid,
- ], true);
+ return self::duplicateAttachment($source, $targetLang);
+ }
- if (is_wp_error($newId) || !$newId) {
+ public static function rewireForLanguage(int $attachmentId, string $postLang): int
+ {
+ if (!self::isMediaTranslated() || $attachmentId <= 0) {
return $attachmentId;
}
- foreach (self::ATTACHMENT_META_KEYS as $metaKey) {
- $value = get_post_meta($attachmentId, $metaKey, true);
- if ($value !== '' && $value !== false && $value !== null) {
- update_post_meta($newId, $metaKey, $value);
- }
+ $source = get_post($attachmentId);
+ if (!$source || $source->post_type !== 'attachment') {
+ return $attachmentId;
}
- pll_set_post_language($newId, $targetLang);
+ $currentLang = (string) pll_get_post_language($attachmentId);
- $group = pll_get_post_translations($attachmentId);
- $group[$targetLang] = $newId;
- pll_save_post_translations($group);
+ if ($currentLang === $postLang) {
+ return $attachmentId;
+ }
+
+ if ($currentLang === '') {
+ pll_set_post_language($attachmentId, $postLang);
+ return $attachmentId;
+ }
- return $newId;
+ $existing = (int) pll_get_post($attachmentId, $postLang);
+ if ($existing > 0) {
+ return $existing;
+ }
+
+ return self::duplicateAttachment($source, $postLang);
}
public static function rewriteContent(string $content, string $targetLang, string $sourceLang): array
@@ -76,58 +80,118 @@ public static function rewriteContent(string $content, string $targetLang, strin
}
$translations = [];
- $blocks = parse_blocks($content);
- $newBlocks = self::walkBlocks($blocks, $targetLang, $sourceLang, $translations);
- $newContent = serialize_blocks($newBlocks);
+ $resolve = static function (int $id) use ($targetLang, $sourceLang, &$translations): int {
+ if ($id <= 0) {
+ return $id;
+ }
+ if (isset($translations[$id])) {
+ return $translations[$id];
+ }
+ $translated = self::ensureTranslation($id, $targetLang, $sourceLang);
+ $translations[$id] = $translated;
+
+ return $translated;
+ };
- foreach ($translations as $oldId => $newId) {
+ $newContent = self::walkContent($content, $resolve, $translations);
+
+ return ['content' => $newContent, 'translations' => $translations];
+ }
+
+ public static function rewireContentForLanguage(string $content, string $postLang): array
+ {
+ if ($content === '' || !self::isMediaTranslated()) {
+ return ['content' => $content, 'rewires' => []];
+ }
+
+ $rewires = [];
+ $resolve = static function (int $id) use ($postLang, &$rewires): int {
+ if ($id <= 0) {
+ return $id;
+ }
+ if (isset($rewires[$id])) {
+ return $rewires[$id];
+ }
+ $rewired = self::rewireForLanguage($id, $postLang);
+ $rewires[$id] = $rewired;
+
+ return $rewired;
+ };
+
+ $newContent = self::walkContent($content, $resolve, $rewires);
+
+ return ['content' => $newContent, 'rewires' => $rewires];
+ }
+
+ private static function walkContent(string $content, callable $resolve, array $idMap): string
+ {
+ $blocks = parse_blocks($content);
+ $newBlocks = self::walkBlocks($blocks, $resolve);
+ $newContent = serialize_blocks($newBlocks);
+
+ foreach ($idMap as $oldId => $newId) {
if ($oldId !== $newId) {
$newContent = str_replace("wp-image-{$oldId}", "wp-image-{$newId}", $newContent);
}
}
- return ['content' => $newContent, 'translations' => $translations];
+ return $newContent;
}
- private static function walkBlocks(array $blocks, string $targetLang, string $sourceLang, array &$translations): array
+ private static function walkBlocks(array $blocks, callable $resolve): array
{
foreach ($blocks as &$block) {
$name = $block['blockName'] ?? null;
$attrs = $block['attrs'] ?? [];
if (in_array($name, ['core/image', 'core/cover', 'core/media-text'], true) && !empty($attrs['id'])) {
- $attrs['id'] = self::resolve((int) $attrs['id'], $targetLang, $sourceLang, $translations);
+ $attrs['id'] = $resolve((int) $attrs['id']);
}
if ($name === 'core/gallery' && !empty($attrs['ids']) && is_array($attrs['ids'])) {
- $attrs['ids'] = array_map(
- fn ($id) => self::resolve((int) $id, $targetLang, $sourceLang, $translations),
- $attrs['ids']
- );
+ $attrs['ids'] = array_map(static fn ($id) => $resolve((int) $id), $attrs['ids']);
}
$block['attrs'] = $attrs;
if (!empty($block['innerBlocks'])) {
- $block['innerBlocks'] = self::walkBlocks($block['innerBlocks'], $targetLang, $sourceLang, $translations);
+ $block['innerBlocks'] = self::walkBlocks($block['innerBlocks'], $resolve);
}
}
return $blocks;
}
- private static function resolve(int $sourceId, string $targetLang, string $sourceLang, array &$translations): int
+ private static function duplicateAttachment(WP_Post $source, string $targetLang): int
{
- if ($sourceId <= 0) {
- return $sourceId;
+ $newId = wp_insert_post([
+ 'post_type' => 'attachment',
+ 'post_title' => $source->post_title,
+ 'post_content' => $source->post_content,
+ 'post_excerpt' => $source->post_excerpt,
+ 'post_mime_type' => $source->post_mime_type,
+ 'post_status' => 'inherit',
+ 'post_parent' => 0,
+ 'guid' => $source->guid,
+ ], true);
+
+ if (is_wp_error($newId) || !$newId) {
+ return (int) $source->ID;
}
- if (isset($translations[$sourceId])) {
- return $translations[$sourceId];
+
+ foreach (self::ATTACHMENT_META_KEYS as $metaKey) {
+ $value = get_post_meta($source->ID, $metaKey, true);
+ if ($value !== '' && $value !== false && $value !== null) {
+ update_post_meta($newId, $metaKey, $value);
+ }
}
- $translated = self::ensureTranslation($sourceId, $targetLang, $sourceLang);
- $translations[$sourceId] = $translated;
+ pll_set_post_language($newId, $targetLang);
+
+ $group = pll_get_post_translations($source->ID);
+ $group[$targetLang] = $newId;
+ pll_save_post_translations($group);
- return $translated;
+ return (int) $newId;
}
}
diff --git a/src/Features/Mcp/Rewire.php b/src/Features/Mcp/Rewire.php
new file mode 100644
index 0000000..003cfc1
--- /dev/null
+++ b/src/Features/Mcp/Rewire.php
@@ -0,0 +1,141 @@
+ __('Rewire attachments to post language', 'spoko-enhanced-rest-api'),
+ 'description' => __('Backfill an existing post or page so featured image and Gutenberg image/gallery/cover/media-text block attachment IDs match the post\'s Polylang language. Untagged attachments are assigned the post\'s language in place; cross-language attachments are swapped for the matching language translation (created on demand, sharing the underlying file). Use this on posts that were created before posts-translate existed.', 'spoko-enhanced-rest-api'),
+ 'category' => 'spoko-content',
+ 'input_schema' => self::inputSchema(),
+ 'output_schema' => self::outputSchema(),
+ 'permission_callback' => [self::class, 'canRewire'],
+ 'execute_callback' => [self::class, 'execute'],
+ 'meta' => ['annotations' => ['readonly' => false, 'destructive' => false, 'idempotent' => true]],
+ ]);
+ }
+
+ public static function canRewire(array $input): bool|WP_Error
+ {
+ $id = (int) ($input['post_id'] ?? 0);
+ if (!in_array(get_post_type($id), self::ALLOWED_POST_TYPES, true)) {
+ return new WP_Error('not_supported', __('Target must be a post or page.', 'spoko-enhanced-rest-api'), ['status' => 404]);
+ }
+ if (!current_user_can('edit_post', $id)) {
+ return new WP_Error('forbidden', __('You cannot edit this post.', 'spoko-enhanced-rest-api'), ['status' => 403]);
+ }
+
+ return true;
+ }
+
+ public static function execute(array $input): array|WP_Error
+ {
+ if (!function_exists('pll_get_post_language')) {
+ return new WP_Error('no_polylang', __('Polylang is not active.', 'spoko-enhanced-rest-api'));
+ }
+
+ $postId = (int) $input['post_id'];
+ $post = get_post($postId);
+ if (!$post) {
+ return new WP_Error('not_found', __('Post does not exist.', 'spoko-enhanced-rest-api'), ['status' => 404]);
+ }
+
+ $postLang = (string) pll_get_post_language($postId);
+ if ($postLang === '') {
+ return new WP_Error('no_language', __('Post has no Polylang language assigned.', 'spoko-enhanced-rest-api'), ['status' => 400]);
+ }
+
+ if (!MediaTranslator::isMediaTranslated()) {
+ return [
+ 'post_updated' => false,
+ 'featured_rewire' => null,
+ 'content_rewires' => [],
+ 'note' => 'Polylang media translations are disabled; nothing to rewire.',
+ ];
+ }
+
+ $featuredRewire = null;
+ $oldThumb = (int) get_post_thumbnail_id($postId);
+ if ($oldThumb > 0) {
+ $newThumb = MediaTranslator::rewireForLanguage($oldThumb, $postLang);
+ if ($newThumb !== $oldThumb) {
+ set_post_thumbnail($postId, $newThumb);
+ $featuredRewire = ['old_id' => $oldThumb, 'new_id' => $newThumb];
+ }
+ }
+
+ $contentResult = MediaTranslator::rewireContentForLanguage($post->post_content, $postLang);
+ $contentRewires = [];
+ foreach ($contentResult['rewires'] as $oldId => $newId) {
+ if ((int) $oldId !== (int) $newId) {
+ $contentRewires[] = ['old_id' => (int) $oldId, 'new_id' => (int) $newId];
+ }
+ }
+
+ $postUpdated = false;
+ if ($contentResult['content'] !== $post->post_content) {
+ $update = wp_update_post([
+ 'ID' => $postId,
+ 'post_content' => $contentResult['content'],
+ ], true);
+ if (is_wp_error($update)) {
+ return $update;
+ }
+ $postUpdated = true;
+ }
+
+ return [
+ 'post_updated' => $postUpdated,
+ 'featured_rewire' => $featuredRewire,
+ 'content_rewires' => $contentRewires,
+ ];
+ }
+
+ private static function inputSchema(): array
+ {
+ return [
+ 'type' => 'object',
+ 'required' => ['post_id'],
+ 'properties' => [
+ 'post_id' => ['type' => 'integer', 'minimum' => 1, 'description' => 'Post or page ID to rewire.'],
+ ],
+ ];
+ }
+
+ private static function outputSchema(): array
+ {
+ return [
+ 'type' => 'object',
+ 'properties' => [
+ 'post_updated' => ['type' => 'boolean', 'description' => 'True if post_content was rewritten.'],
+ 'featured_rewire' => [
+ 'type' => ['object', 'null'],
+ 'properties' => [
+ 'old_id' => ['type' => 'integer'],
+ 'new_id' => ['type' => 'integer'],
+ ],
+ ],
+ 'content_rewires' => [
+ 'type' => 'array',
+ 'items' => [
+ 'type' => 'object',
+ 'properties' => [
+ 'old_id' => ['type' => 'integer'],
+ 'new_id' => ['type' => 'integer'],
+ ],
+ ],
+ ],
+ 'note' => ['type' => 'string', 'description' => 'Present only when no rewire was possible (e.g. media translations disabled).'],
+ ],
+ ];
+ }
+}