| @@ -35,13 +35,44 @@ | ||
| 35 | 35 | | Registered by | `wordpress/mcp-adapter` `DefaultServerFactory::create()` on `mcp_adapter_init` | |
| 36 | 36 | |
| 37 | 37 | The endpoint returns HTTP 200 with the adapter's default three-tool set plus all registered Imagify abilities. |
| 38 | 38 | |
| 39 | +## OAuth (Claude Desktop / MCP clients) | |
| 40 | + | |
| 41 | +Imagify bundles the shared `wp-media/mcp-oauth` library (`^1.0.2`) to expose an OAuth 2.1 + PKCE authenticated MCP server for clients such as Claude Desktop. Imagify contains **no custom OAuth code** — the library owns the entire flow (authorize / consent / token / revoke endpoints, `.well-known` discovery documents, and the isolated server registration). | |
| 42 | + | |
| 43 | +The library is booted in `imagify_init()` in `inc/main.php`, guarded by `class_exists( \WPMedia\MCP\OAuth\Bootstrap::class )`, inside the same `$can_boot_mcp_adapter` guard as the `wordpress/mcp-adapter` boot it depends on: | |
| 44 | + | |
| 45 | +```php | |
| 46 | +if ( $can_boot_mcp_adapter ) { | |
| 47 | + \WP\MCP\Core\McpAdapter::instance(); | |
| 48 | + | |
| 49 | + if ( class_exists( \WPMedia\MCP\OAuth\Bootstrap::class ) ) { | |
| 50 | + \WPMedia\MCP\OAuth\Bootstrap::instance(); | |
| 51 | + } | |
| 52 | +} | |
| 53 | +``` | |
| 54 | + | |
| 55 | +The server is enabled by default from `wp-media/mcp-oauth` v1.0.2 onward, so no `wpmedia_mcp_oauth_server_enabled` filter is needed. It can still be disabled by filtering that value to `false`. | |
| 56 | + | |
| 57 | +| Key | Value | | |
| 58 | +|-----|-------| | |
| 59 | +| OAuth server path | `/wp-json/mcp/mcp-oauth-server` | | |
| 60 | +| Discovery | `/.well-known/oauth-authorization-server`, `/.well-known/oauth-protected-resource` | | |
| 61 | +| Tools exposed | `mcp-adapter/discover-abilities`, `mcp-adapter/get-ability-info`, `mcp-adapter/execute-ability` | | |
| 62 | +| Auth | OAuth 2.1 + PKCE, JWT bearer tokens | | |
| 63 | + | |
| 64 | +The OAuth server's fixed tool list is only the three generic mcp-adapter tools, but `discover-abilities` and `execute-ability` read from the **global** WordPress Abilities registry (`wp_get_abilities()` / `wp_get_ability()`), not a fixed category — so all `imagify/*` abilities registered by `AbilitiesSubscriber` are fully discoverable and callable through the OAuth server, with no extra wiring needed. `ConfigSubscriber`'s `mcp_adapter_default_server_config` filter is unaffected — it only customizes the unauthenticated default server, not the OAuth server created by the library. | |
| 65 | + | |
| 39 | 66 | ## Classes |
| 40 | 67 | |
| 41 | 68 | | Class | Responsibility | |
| 42 | 69 | |-------|----------------| |
| 43 | 70 | | `Imagify\Abilities\AbilitiesInterface` | Contract every Imagify MCP ability must implement. | |
| 71 | +| `Imagify\Abilities\AbstractAbility` | Base class for abilities; provides `check_permissions()`, `fire_executed()`, and `guard_credit_confirmation()` (see [Credit confirmation flow](#credit-confirmation-flow)). | | |
| 72 | +| `Imagify\Abilities\CreditConsumingAbilityInterface` | Contract for abilities that spend Imagify quota (`get_impact_estimate()`); opts an ability into `guard_credit_confirmation()`. Implemented by `OptimizeMedia`, `BulkOptimize`, `GenerateMissingNextgen`. | | |
| 73 | +| `Imagify\Abilities\BulkOptimize` | MCP ability: schedule a bulk image optimization run (`imagify/bulk-optimize`). | | |
| 74 | +| `Imagify\Abilities\GenerateMissingNextgen` | MCP ability: queue generation of missing next-gen (WebP/AVIF) versions (`imagify/generate-missing-nextgen`). | | |
| 44 | 75 | | `Imagify\Abilities\GetAccount` | Ability `imagify/get-account` — returns account quota, plan details, and API key validity. | |
| 45 | 76 | | `Imagify\Abilities\GetMediaStatus` | Ability `imagify/get-media-status` — returns optimization status and metrics for a media attachment. | |
| 46 | 77 | | `Imagify\Abilities\GetNextgenCoverage` | Ability `imagify/get-nextgen-coverage` — returns the count of optimized images missing a next-gen version and the configured format. | |
| 47 | 78 | | `Imagify\Abilities\GetSettings` | Ability `imagify/get-settings` — returns all Imagify configuration options (redacting `api_key` and `version`). | |
| @@ -46,8 +77,9 @@ | ||
| 46 | 77 | | `Imagify\Abilities\GetNextgenCoverage` | Ability `imagify/get-nextgen-coverage` — returns the count of optimized images missing a next-gen version and the configured format. | |
| 47 | 78 | | `Imagify\Abilities\GetSettings` | Ability `imagify/get-settings` — returns all Imagify configuration options (redacting `api_key` and `version`). | |
| 48 | 79 | | `Imagify\Abilities\GetStats` | Ability `imagify/get-stats` — returns optimization statistics for WP media and custom folders. | |
| 49 | 80 | | `Imagify\Abilities\OptimizeMedia` | Ability `imagify/optimize-media` — optimizes a WP media attachment on demand. | |
| 81 | +| `Imagify\Abilities\RestoreMedia` | MCP ability: restore an optimized media to its original state via backup (`imagify/restore-media`). | | |
| 50 | 82 | | `Imagify\Abilities\UpdateSettings` | MCP ability: updates one or more Imagify configuration settings. | |
| 51 | 83 | | `Imagify\MCP\ConfigSubscriber` | Customizes the MCP server name and description via `mcp_adapter_default_server_config`. | |
| 52 | 84 | | `Imagify\MCP\AbilitiesSubscriber` | Registers the `imagify` ability category and all injected abilities. | |
| 53 | 85 | | `Imagify\MCP\ServiceProvider` | DI wiring — registered in `config/providers.php`. | |
| @@ -69,8 +101,156 @@ | ||
| 69 | 101 | - `execute()` — returns the tool-result value (array, string, or any MCP-compatible type). |
| 70 | 102 | |
| 71 | 103 | ## Registered abilities |
| 72 | 104 | |
| 105 | +### Identifying a media | |
| 106 | + | |
| 107 | +`imagify/optimize-media`, `imagify/restore-media` and `imagify/get-media-status` accept three | |
| 108 | +interchangeable identifiers, so a caller that only knows the file name or the URL of an image does | |
| 109 | +not have to look its attachment ID up first. None of the three is required on its own — exactly one | |
| 110 | +must be supplied (hence `no*` in the input tables): | |
| 111 | + | |
| 112 | +| Input | Resolution | | |
| 113 | +|-------|------------| | |
| 114 | +| `media_id` | Used as-is. | | |
| 115 | +| `media_url` | `attachment_url_to_postid()`. Next-gen and thumbnail URLs (`hero-300x200.jpg.webp`) fall back to a file-name lookup on the original. | | |
| 116 | +| `media_filename` | Exact, case-insensitive match on the base name of the `_wp_attached_file` meta. A full relative path (`2026/08/hero.jpg`) is accepted too. | | |
| 117 | + | |
| 118 | +`media_id` wins over `media_url`, which wins over `media_filename`. A `media_id` of `0` or less is | |
| 119 | +treated as absent, so the next identifier is tried. | |
| 120 | + | |
| 121 | +Errors are explicit and actionable rather than best-effort: | |
| 122 | + | |
| 123 | +| Error code | When | | |
| 124 | +|------------|------| | |
| 125 | +| `imagify_missing_media_identifier` | None of the three inputs was usable. | | |
| 126 | +| `imagify_media_not_found` | The URL or file name matches nothing in the library. | | |
| 127 | +| `imagify_ambiguous_media_filename` | Several attachments share the file name. The message lists the matching IDs so the caller can retry with `media_id`. | | |
| 128 | + | |
| 129 | +Resolution lives in `Imagify\Abilities\MediaResolver::resolve_id()`, which returns an attachment ID | |
| 130 | +or a `WP_Error`. Each ability turns that `WP_Error` into its own `status: "error"` output shape. | |
| 131 | + | |
| 132 | +### Credit confirmation flow | |
| 133 | + | |
| 134 | +`imagify/optimize-media`, `imagify/bulk-optimize`, and `imagify/generate-missing-nextgen` consume | |
| 135 | +Imagify quota, so each one is gated by `Imagify\Abilities\AbstractAbility::guard_credit_confirmation()` | |
| 136 | +before its real side effect runs. Because MCP tool calls are made by an AI agent rather than a human | |
| 137 | +clicking a confirm dialog, the "explicit confirmation" requirement is implemented as a two-call | |
| 138 | +protocol instead of a client-side dialog: | |
| 139 | + | |
| 140 | +1. **Preview call** — call the ability without `confirm` (or with `confirm: false`). The guard never | |
| 141 | + invokes the ability's real logic; it returns a `confirmation_required` response describing the | |
| 142 | + impact (unit, count, and — for `bulk-optimize`/`generate-missing-nextgen` — the total) and the | |
| 143 | + current quota remaining. | |
| 144 | +2. **Confirmed call** — resend the same call with `confirm: true`. The guard re-checks the API key and | |
| 145 | + quota (they may have changed since the preview) and, if both are still fine, invokes the ability's | |
| 146 | + real logic and returns its normal result. | |
| 147 | + | |
| 148 | +The guard runs this exact 4-step sequence on every call, confirmed or not: | |
| 149 | + | |
| 150 | +1. `Imagify_Requirements::is_api_key_valid()` — if false, returns `status: invalid_api_key` and stops. | |
| 151 | +2. `Imagify_Requirements::is_over_quota()` — if true, returns `status: insufficient_quota` and stops. | |
| 152 | +3. `$args['confirm'] === true` (strict boolean check; `"true"` as a string does not count) — if not | |
| 153 | + true, returns `status: confirmation_required` and stops. | |
| 154 | +4. Otherwise, runs the ability's real logic and returns its result unchanged. | |
| 155 | + | |
| 156 | +A confirmed call is **never** allowed to skip the quota re-check — only the confirmation step is | |
| 157 | +skipped. This closes the race where quota is exhausted between the preview and the confirmed call. | |
| 158 | + | |
| 159 | +**`confirmation_required` response shape:** | |
| 160 | + | |
| 161 | +| Field | Type | Description | | |
| 162 | +|-------|------|-------------| | |
| 163 | +| `status` | `"confirmation_required"` | | | |
| 164 | +| `message` | string | Human-readable summary of what will happen and how much quota remains. | | |
| 165 | +| `impact` | object | `{ unit, count }` for `optimize-media`; `{ unit, count, total }` for `bulk-optimize` / `generate-missing-nextgen`. | | |
| 166 | +| `quota_remaining` | number | Percentage of quota still available. | | |
| 167 | +| `confirm_with` | object | `{ "confirm": true }` — tells the caller exactly which argument to resend. | | |
| 168 | + | |
| 169 | +**`insufficient_quota` response shape:** | |
| 170 | + | |
| 171 | +| Field | Type | Description | | |
| 172 | +|-------|------|-------------| | |
| 173 | +| `status` | `"insufficient_quota"` | | | |
| 174 | +| `message` | string | Human-readable explanation that quota is exhausted. | | |
| 175 | +| `next_date_update` | string | ISO date of the next quota reset, or `""` if unknown. | | |
| 176 | +| `upgrade_url` | string | Link to the Imagify subscription/upgrade page. | | |
| 177 | + | |
| 178 | +**`invalid_api_key` response shape:** | |
| 179 | + | |
| 180 | +| Field | Type | Description | | |
| 181 | +|-------|------|-------------| | |
| 182 | +| `status` | `"invalid_api_key"` | | | |
| 183 | +| `message` | string | Human-readable explanation that the API key is invalid or missing. | | |
| 184 | + | |
| 185 | +Every credit-consuming ability's `input_schema` includes a `confirm` boolean property (default | |
| 186 | +`false`, not in `required`) and every credit-consuming ability's `output_schema` `status` enum | |
| 187 | +includes `confirmation_required`, `insufficient_quota`, and `invalid_api_key` alongside its normal | |
| 188 | +success/error values. | |
| 189 | + | |
| 190 | +### `imagify/bulk-optimize` | |
| 191 | + | |
| 192 | +**Class:** `Imagify\Abilities\BulkOptimize` | |
| 193 | +**Capability required:** `manage_options` | |
| 194 | +**Exposed via REST:** yes (`show_in_rest: true`) | |
| 195 | +**MCP discoverable:** yes (`mcp.public: true`) | |
| 196 | + | |
| 197 | +Schedules a bulk image optimization run for the WordPress media library or custom folders. The operation is asynchronous — the ability returns immediately after dispatching via Action Scheduler / WP-Cron. | |
| 198 | + | |
| 199 | +**Input schema:** | |
| 200 | + | |
| 201 | +| Field | Type | Required | Description | | |
| 202 | +|-------|------|----------|-------------| | |
| 203 | +| `context` | string | yes | `"wp"` for the WordPress media library or `"custom-folders"` for custom folder sources. Enum: `["wp", "custom-folders"]`. | | |
| 204 | +| `optimization_level` | integer (0–2) | no | Overrides the global setting. 0 = normal, 1 = aggressive, 2 = ultra. | | |
| 205 | +| `confirm` | boolean | no | Set to `true` to execute after reviewing the credit-consumption preview returned by a prior call without this flag. Defaults to `false`. See [Credit confirmation flow](#credit-confirmation-flow). | | |
| 206 | + | |
| 207 | +**Output schema:** | |
| 208 | + | |
| 209 | +| Field | Type | Description | | |
| 210 | +|-------|------|-------------| | |
| 211 | +| `status` | `"scheduled"` \| `"error"` \| `"confirmation_required"` \| `"insufficient_quota"` \| `"invalid_api_key"` | Result status. | | |
| 212 | +| `context` | string | The requested optimization context, echoed back. Present only on `scheduled`/`error`. | | |
| 213 | +| `error_message` | string \| null | Human-readable error on failure, null on success. Present only on `scheduled`/`error`. | | |
| 214 | + | |
| 215 | +`get_impact_estimate()`'s `count`/`total` figures come from cheap COUNT-only queries per context: | |
| 216 | +`imagify_count_unoptimized_attachments()` / `imagify_count_attachments()` for `"wp"`, | |
| 217 | +`Imagify_Files_Stats::count_unoptimized_files()` / `count_files()` for `"custom-folders"`. Any | |
| 218 | +context other than `"custom-folders"` (including an unrecognized value or a missing `context` key) | |
| 219 | +is normalized to `"wp"` before the counts and translatable `impact.label` are computed, so the | |
| 220 | +label always matches the branch the counts came from. | |
| 221 | + | |
| 222 | +### `imagify/generate-missing-nextgen` | |
| 223 | + | |
| 224 | +**Class:** `Imagify\Abilities\GenerateMissingNextgen` | |
| 225 | +**Capability required:** `manage_options` | |
| 226 | +**Exposed via REST:** yes (`show_in_rest: true`) | |
| 227 | +**MCP discoverable:** yes (`mcp.public: true`) | |
| 228 | + | |
| 229 | +Queues generation of missing next-gen (WebP/AVIF) versions for all optimized media by delegating to `Bulk::run_generate_nextgen()`. Runs asynchronously via Action Scheduler. | |
| 230 | + | |
| 231 | +`status=scheduled` is returned both when jobs were enqueued (`queued_count > 0`) and when there is nothing to generate (`queued_count=0`). The latter is a successful no-op, not an error. | |
| 232 | + | |
| 233 | +**Input schema:** | |
| 234 | + | |
| 235 | +| Field | Type | Required | Description | | |
| 236 | +|-------|------|----------|-------------| | |
| 237 | +| `confirm` | boolean | no | Set to `true` to execute after reviewing the credit-consumption preview returned by a prior call without this flag. Defaults to `false`. See [Credit confirmation flow](#credit-confirmation-flow). | | |
| 238 | + | |
| 239 | +**Output schema:** | |
| 240 | + | |
| 241 | +| Field | Type | Description | | |
| 242 | +|-------|------|-------------| | |
| 243 | +| `status` | `"scheduled"` \| `"error"` \| `"confirmation_required"` \| `"insufficient_quota"` \| `"invalid_api_key"` | Result status. | | |
| 244 | +| `queued_count` | integer | Number of images queued for next-gen generation. Present only on `scheduled`/`error`. | | |
| 245 | +| `error_message` | string \| null | Human-readable error on failure, null on success. Present only on `scheduled`/`error`. | | |
| 246 | + | |
| 247 | +`get_impact_estimate()`'s `count` comes from the live `Imagify\Stats\OptimizedMediaWithoutNextGen` | |
| 248 | +stat service (`get_stat()`, injected via DI — not the 2-day cached `get_cached_stat()`, since a stale | |
| 249 | +count could exceed `total` and mislead the credit-spend decision); `total` sums | |
| 250 | +`imagify_count_optimized_attachments() + Imagify_Files_Stats::count_optimized_files()`. `count` is | |
| 251 | +clamped to `total` as a defensive safeguard. | |
| 252 | + | |
| 73 | 253 | ### `imagify/optimize-media` |
| 74 | 254 | |
| 75 | 255 | Registered by `Imagify\Abilities\OptimizeMedia`. Optimizes a specific WordPress media library attachment on demand. |
| 76 | 256 | |
| @@ -87,19 +267,51 @@ | ||
| 87 | 267 | **Input schema:** |
| 88 | 268 | |
| 89 | 269 | | Field | Type | Required | Description | |
| 90 | 270 | |-------|------|----------|-------------| |
| 91 | -| `media_id` | integer | yes | WordPress attachment ID. | | |
| 271 | +| `media_id` | integer | no* | WordPress attachment ID. | | |
| 272 | +| `media_filename` | string | no* | File name of the media, for example `hero-banner.jpg`. | | |
| 273 | +| `media_url` | string | no* | Absolute URL of the media. | | |
| 92 | 274 | | `optimization_level` | integer (0–2) | no | Overrides the global setting. 0 = normal, 1 = aggressive, 2 = ultra. | |
| 275 | +| `confirm` | boolean | no | Set to `true` to execute after reviewing the credit-consumption preview returned by a prior call without this flag. Defaults to `false`. See [Credit confirmation flow](#credit-confirmation-flow). | | |
| 93 | 276 | |
| 94 | 277 | **Output schema:** |
| 95 | 278 | |
| 96 | 279 | | Field | Type | Description | |
| 97 | 280 | |-------|------|-------------| |
| 281 | +| `status` | `"success"` \| `"error"` \| `"confirmation_required"` \| `"insufficient_quota"` \| `"invalid_api_key"` | Result status. | | |
| 282 | +| `original_size` | integer \| null | File size in bytes before optimization, or null on error. Present only on `success`/`error`. | | |
| 283 | +| `optimized_size` | integer \| null | File size in bytes after optimization (may be null if the background job is not yet complete). Present only on `success`/`error`. | | |
| 284 | +| `savings_percent` | float \| null | Percentage savings, or null on error or when sizes are unavailable. Present only on `success`/`error`. | | |
| 285 | +| `error_message` | string \| null | Human-readable error on failure, null on success. Present only on `success`/`error`. | | |
| 286 | + | |
| 287 | +`get_impact_estimate()` always returns `{ unit: "image", count: 1, label: "this media" }` — optimizing | |
| 288 | +a single media always costs exactly one unit, so `impact.total` is omitted from the | |
| 289 | +`confirmation_required` response for this ability. | |
| 290 | + | |
| 291 | +### `imagify/restore-media` | |
| 292 | + | |
| 293 | +**Class:** `Imagify\Abilities\RestoreMedia` | |
| 294 | +**Capability required:** `manage_options` | |
| 295 | +**Exposed via REST:** yes (`show_in_rest: true`) | |
| 296 | +**MCP discoverable:** yes (`mcp.public: true`) | |
| 297 | + | |
| 298 | +Restores an optimized media to its original state using the stored backup file. Requires that backup was enabled (`backup: 1` in settings) at the time of optimization. | |
| 299 | + | |
| 300 | +**Input schema:** | |
| 301 | + | |
| 302 | +| Field | Type | Required | Description | | |
| 303 | +|-------|------|----------|-------------| | |
| 304 | +| `media_id` | integer | no* | WordPress attachment ID to restore. | | |
| 305 | +| `media_filename` | string | no* | File name of the media, for example `hero-banner.jpg`. | | |
| 306 | +| `media_url` | string | no* | Absolute URL of the media. | | |
| 307 | + | |
| 308 | +**Output schema:** | |
| 309 | + | |
| 310 | +| Field | Type | Description | | |
| 311 | +|-------|------|-------------| | |
| 98 | 312 | | `status` | `"success"` \| `"error"` | Result status. | |
| 99 | -| `original_size` | integer \| null | File size in bytes before optimization, or null on error. | | |
| 100 | -| `optimized_size` | integer \| null | File size in bytes after optimization (may be null if the background job is not yet complete). | | |
| 101 | -| `savings_percent` | float \| null | Percentage savings, or null on error or when sizes are unavailable. | | |
| 313 | +| `restored_size` | integer \| null | Restored original file size in bytes, or null on error. | | |
| 102 | 314 | | `error_message` | string \| null | Human-readable error on failure, null on success. | |
| 103 | 315 | |
| 104 | 316 | ## Hooks |
| 105 | 317 | |
| @@ -201,9 +413,11 @@ | ||
| 201 | 413 | **Input schema:** |
| 202 | 414 | |
| 203 | 415 | | Field | Type | Required | Description | |
| 204 | 416 | |-------|------|----------|-------------| |
| 205 | -| `media_id` | integer | yes | WordPress attachment ID. | | |
| 417 | +| `media_id` | integer | no* | WordPress attachment ID. | | |
| 418 | +| `media_filename` | string | no* | File name of the media, for example `hero-banner.jpg`. | | |
| 419 | +| `media_url` | string | no* | Absolute URL of the media. | | |
| 206 | 420 | |
| 207 | 421 | **Output schema:** |
| 208 | 422 | |
| 209 | 423 | | Field | Type | Description | |
| @@ -215,9 +429,9 @@ | ||
| 215 | 429 | | `webp_available` | boolean | `true` when a WebP version of the full-size image has been generated. | |
| 216 | 430 | | `avif_available` | boolean | `true` when an AVIF version of the full-size image has been generated. | |
| 217 | 431 | | `error_message` | string \| null | Human-readable error message when `status` is `"error"`. `null` otherwise. | |
| 218 | 432 | |
| 219 | -**Error behaviour:** If `media_id` is `0` or negative, or the attachment does not exist in the database, returns `status: "error"` with a descriptive `error_message`. | |
| 433 | +**Error behaviour:** If no identifier resolves to an attachment, returns `status: "error"` with a descriptive `error_message`. See [Identifying a media](#identifying-a-media). | |
| 220 | 434 | |
| 221 | 435 | ### `imagify/get-nextgen-coverage` |
| 222 | 436 | |
| 223 | 437 | Registered by `Imagify\Abilities\GetNextgenCoverage`. Returns the number of optimized images that are missing a next-gen (WebP/AVIF) version, and the next-gen format currently configured in Imagify settings. |