From 09f6db3dbc1bf4f934fa4a3e09aec70327572a9b Mon Sep 17 00:00:00 2001 From: Manuel Tuero Date: Thu, 27 Aug 2026 16:27:29 -0300 Subject: [PATCH] feat: add upload-media-file ability for local image uploads Adds a third image upload path so agents use file_path instead of unreliable base64 payloads for editor-attached images. Closes #1. --- docs/ABILITIES.md | 24 +++++++- includes/Abilities/AbilityRegistrar.php | 28 ++++++++- includes/Services/MediaService.php | 71 +++++++++++++++++++++++ tests/upload-media-file.php | 75 +++++++++++++++++++++++++ wp-editorial-abilities.php | 4 +- 5 files changed, 195 insertions(+), 7 deletions(-) create mode 100644 tests/upload-media-file.php diff --git a/docs/ABILITIES.md b/docs/ABILITIES.md index dd139b0..a2a920e 100644 --- a/docs/ABILITIES.md +++ b/docs/ABILITIES.md @@ -25,9 +25,10 @@ Category: `editorial-write`. | --- | --- | | `create-editorial-draft` | Create a draft from title, content, categories, tags, media, SEO | | `update-editorial-draft` | Update an existing draft or pending post | -| `attach-images-to-draft` | Attach media IDs or sideload image URLs to a post | +| `attach-images-to-draft` | Attach media IDs or sideload publicly accessible image URLs to a post | | `set-featured-image` | Set an attachment as the featured image | -| `upload-media-base64` | Upload a file from a base64 payload to the media library | +| `upload-media-base64` | Upload a small file from a base64 payload (fallback for tiny payloads only) | +| `upload-media-file` | Upload a file from a local file path (preferred for editor-attached images) | | `suggest-internal-links` | Suggest existing posts to link from a draft | | `optimize-seo-metadata` | Write Yoast and/or Rank Math SEO metadata | | `schedule-post` | Schedule a post for future publication | @@ -37,7 +38,7 @@ Category: `editorial-write`. - Read abilities: `edit_posts`. - Draft creation: `edit_posts`. -- Media upload (`upload-media-base64`): `upload_files`. +- Media upload (`upload-media-base64`, `upload-media-file`): `upload_files`. - Edit abilities: `edit_post` on the target post ID. - Publish / schedule: `edit_post` + `publish_posts` on the target post ID. @@ -57,3 +58,20 @@ Category: `editorial-write`. | `both` | Write both sets of meta fields | Supported fields include SEO title, meta description, focus keywords, canonical URL, and Open Graph / Twitter metadata. + +## Image upload paths + +Three abilities cover different image sources. Choose based on where the image lives — do not base64-encode local files inside tool calls. + +| Scenario | Ability | Input | +| --- | --- | --- | +| Image already on the public web | `attach-images-to-draft` | `image_urls` | +| Local file (editor pasted or attached in chat) | `upload-media-file` | `file_path` | +| Tiny payload already available as base64 | `upload-media-base64` | `base64_data` | + +Typical flow for a local editor image: + +1. `upload-media-file` with `file_path` (and optional `alt_text` / `caption`) → returns `media_id`. +2. `set-featured-image` or `attach-images-to-draft` with that `media_id`. + +**Remote WordPress + local MCP client:** `file_path` must be readable where the ability callback runs. When WordPress is remote and the file is on the editor's machine, the MCP proxy must intercept `upload-media-file` and upload the binary directly (e.g. to `/wp-json/wp/v2/media`) before attaching. See [issue #1](https://github.com/BlackBoxVision/wp-editorial-abilities/issues/1). diff --git a/includes/Abilities/AbilityRegistrar.php b/includes/Abilities/AbilityRegistrar.php index 3365324..1ecd18d 100644 --- a/includes/Abilities/AbilityRegistrar.php +++ b/includes/Abilities/AbilityRegistrar.php @@ -172,7 +172,10 @@ private function registerWriteAbilities(): void $this->ability('attach-images-to-draft', [ 'label' => __('Attach Images to Draft', 'wp-editorial-abilities'), - 'description' => __('Attach existing media IDs or sideload image URLs to an editable post.', 'wp-editorial-abilities'), + 'description' => __( + 'Attach existing media IDs or sideload publicly accessible image URLs to an editable post. Use image_urls when the image is already hosted on the internet (e.g. the featured image from a source article). Do NOT use image_urls for files the editor pasted or uploaded locally in the chat — use upload-media-file instead.', + 'wp-editorial-abilities' + ), 'category' => 'editorial-write', 'input_schema' => $this->objectSchema([ 'post_id' => ['type' => 'integer', 'required' => true], @@ -201,7 +204,10 @@ private function registerWriteAbilities(): void $this->ability('upload-media-base64', [ 'label' => __('Upload Media From Base64', 'wp-editorial-abilities'), - 'description' => __('Upload a file (typically an image the editor shared directly in chat) to the media library from a base64-encoded payload. Returns the attachment ID and URL so it can be used as a featured image or attached to a draft.', 'wp-editorial-abilities'), + 'description' => __( + 'Upload a small file to the media library from an already-encoded base64 payload. Fallback only — prefer upload-media-file for local files and attach-images-to-draft with image_urls for public URLs. Do NOT base64-encode large local files inside a tool call.', + 'wp-editorial-abilities' + ), 'category' => 'editorial-write', 'input_schema' => $this->objectSchema([ 'filename' => ['type' => 'string', 'required' => true, 'description' => 'Filename including extension, e.g. cover.png.'], @@ -214,6 +220,24 @@ private function registerWriteAbilities(): void 'meta' => $this->publicMeta(['readonly' => false, 'destructive' => false, 'idempotent' => false]), ]); + $this->ability('upload-media-file', [ + 'label' => __('Upload Media From File Path', 'wp-editorial-abilities'), + 'description' => __( + 'Upload a file from a local file path to the media library. Use this when the image is a local file with no public URL (e.g. one the editor pasted or uploaded directly in the conversation). Returns the attachment ID and URL. Do NOT use this for images that already have a public URL (use attach-images-to-draft with image_urls instead), and do NOT try to base64-encode the file for this ability.', + 'wp-editorial-abilities' + ), + 'category' => 'editorial-write', + 'input_schema' => $this->objectSchema([ + 'file_path' => ['type' => 'string', 'required' => true, 'description' => 'Absolute path to the file, accessible from wherever this ability executes.'], + 'alt_text' => ['type' => 'string'], + 'caption' => ['type' => 'string'], + ], ['file_path']), + 'output_schema' => $this->objectSchema(), + 'execute_callback' => [$this->media, 'uploadMediaFromFile'], + 'permission_callback' => [$this, 'canUploadFiles'], + 'meta' => $this->publicMeta(['readonly' => false, 'destructive' => false, 'idempotent' => false]), + ]); + $this->ability('suggest-internal-links', [ 'label' => __('Suggest Internal Links', 'wp-editorial-abilities'), 'description' => __('Suggest existing posts to link from a draft based on query text and category.', 'wp-editorial-abilities'), diff --git a/includes/Services/MediaService.php b/includes/Services/MediaService.php index 9ff499b..0e365fe 100644 --- a/includes/Services/MediaService.php +++ b/includes/Services/MediaService.php @@ -121,6 +121,77 @@ public function uploadMediaBase64(array $input): array|WP_Error return $this->mediaResponse($attachment); } + public function uploadMediaFromFile(array $input): array|WP_Error + { + require_once ABSPATH . 'wp-admin/includes/file.php'; + require_once ABSPATH . 'wp-admin/includes/image.php'; + + $file_path = isset($input['file_path']) ? (string) $input['file_path'] : ''; + $alt_text = isset($input['alt_text']) ? sanitize_text_field((string) $input['alt_text']) : ''; + $caption = isset($input['caption']) ? sanitize_text_field((string) $input['caption']) : ''; + + if ($file_path === '' || ! is_readable($file_path)) { + return new WP_Error( + 'wpea_file_not_readable', + sprintf( + /* translators: %s: file path */ + __('Could not read file at "%s". This ability requires the file to be accessible from the environment where WordPress abilities execute — see the architecture note in this issue if the file lives on the MCP client instead.', 'wp-editorial-abilities'), + $file_path + ) + ); + } + + $filename = sanitize_file_name(basename($file_path)); + $contents = file_get_contents($file_path); + + if ($contents === false) { + return new WP_Error( + 'wpea_file_not_readable', + sprintf( + /* translators: %s: file path */ + __('Could not read file at "%s". This ability requires the file to be accessible from the environment where WordPress abilities execute — see the architecture note in this issue if the file lives on the MCP client instead.', 'wp-editorial-abilities'), + $file_path + ) + ); + } + + $upload = wp_upload_bits($filename, null, $contents); + + if (! empty($upload['error'])) { + return new WP_Error('wpea_upload_failed', (string) $upload['error']); + } + + $file = $upload['file']; + $filetype = wp_check_filetype(basename($file), null); + + $attachment_id = wp_insert_attachment([ + 'guid' => $upload['url'], + 'post_mime_type' => $filetype['type'], + 'post_title' => sanitize_text_field(pathinfo($filename, PATHINFO_FILENAME)), + 'post_excerpt' => $caption, + 'post_status' => 'inherit', + ], $file, 0, true); + + if (is_wp_error($attachment_id)) { + return $attachment_id; + } + + $metadata = wp_generate_attachment_metadata($attachment_id, $file); + wp_update_attachment_metadata($attachment_id, $metadata); + + if ($alt_text !== '') { + update_post_meta($attachment_id, '_wp_attachment_image_alt', $alt_text); + } + + $attachment = get_post($attachment_id); + + if (! $attachment instanceof WP_Post) { + return new WP_Error('wpea_attachment_missing', __('Attachment could not be loaded after upload.', 'wp-editorial-abilities')); + } + + return $this->mediaResponse($attachment); + } + public function setFeaturedImage(array $input): array|WP_Error { $post_id = (int) $input['post_id']; diff --git a/tests/upload-media-file.php b/tests/upload-media-file.php new file mode 100644 index 0000000..ffd9978 --- /dev/null +++ b/tests/upload-media-file.php @@ -0,0 +1,75 @@ +uploadMediaFromFile(['file_path' => '/tmp/wpea-does-not-exist-' . wp_generate_password(8, false) . '.png']); +$assert(is_wp_error($missing), 'unreadable path should return WP_Error'); +$assert( + is_wp_error($missing) && $missing->get_error_code() === 'wpea_file_not_readable', + 'unreadable path should use wpea_file_not_readable' +); + +$tmp = wp_tempnam('wpea-upload-test'); +if ($tmp === '') { + fwrite(STDERR, "FAIL: could not create temp file\n"); + exit(1); +} + +$png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', true); +file_put_contents($tmp, $png); + +$uploaded = $media->uploadMediaFromFile([ + 'file_path' => $tmp, + 'alt_text' => 'Integration alt text', + 'caption' => 'Integration caption', +]); + +@unlink($tmp); + +$assert(! is_wp_error($uploaded), 'readable file should upload successfully'); +$assert(is_array($uploaded) && ! empty($uploaded['media_id']), 'upload should return media_id'); +$assert(is_array($uploaded) && ! empty($uploaded['url']), 'upload should return url'); + +if (is_array($uploaded) && ! empty($uploaded['media_id'])) { + $alt = get_post_meta((int) $uploaded['media_id'], '_wp_attachment_image_alt', true); + $assert($alt === 'Integration alt text', 'alt_text should be stored on attachment'); + + $attachment = get_post((int) $uploaded['media_id']); + $assert($attachment instanceof WP_Post, 'attachment post should exist'); + if ($attachment instanceof WP_Post) { + $assert($attachment->post_excerpt === 'Integration caption', 'caption should be stored on attachment'); + } + + wp_delete_attachment((int) $uploaded['media_id'], true); +} + +if ($failures > 0) { + fwrite(STDERR, "{$failures} assertion(s) failed.\n"); + exit(1); +} + +echo "PASS: upload-media-file integration checks\n"; diff --git a/wp-editorial-abilities.php b/wp-editorial-abilities.php index 0e6cba1..c4983e3 100644 --- a/wp-editorial-abilities.php +++ b/wp-editorial-abilities.php @@ -2,7 +2,7 @@ /** * Plugin Name: WP Editorial Abilities * Description: Editorial workflow abilities for WordPress Abilities API and MCP Adapter. - * Version: 0.1.0 + * Version: 0.2.0 * Requires at least: 6.9 * Requires PHP: 8.0 * Author: BlackBox Vision @@ -18,7 +18,7 @@ define('WPEA_PLUGIN_FILE', __FILE__); define('WPEA_PLUGIN_DIR', plugin_dir_path(__FILE__)); -define('WPEA_PLUGIN_VERSION', '0.1.0'); +define('WPEA_PLUGIN_VERSION', '0.2.0'); spl_autoload_register(static function (string $class): void { $prefix = 'WpEditorialAbilities\\';