feat(v3.13.0): settings live DB reads, remove Directus/Appwrite, admin stats

Settings fixes:
- MAX_SITES_PER_USER, USER_RATE_LIMIT_PER_MIN/HR now read from DB settings
  table (DB > ENV > default), so dashboard/settings changes apply without
  restart. Sync cache refreshed on every save or delete.
- /api/me reports the live DB value for max_sites_per_user.

Admin improvements:
- Admin users bypass per-user rate limiting entirely (role=admin or ADMIN_EMAILS).
- Admin Overview now shows platform stats: registered users, new users (7d),
  total user sites, available tools.

Plugin cleanup:
- Appwrite and Directus plugins removed from the active registry (8 plugins
  now: WordPress, WooCommerce, WordPress Specialist, Gitea, n8n, Supabase,
  OpenPanel, Coolify). Plugin code is retained for future re-enabling.
- Settings page plugin visibility list updated to match.

Mobile onboarding:
- Stepper steps on narrow viewports stack vertically with correct full border
  and rounded corners on each step.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-20 23:33:20 +02:00
parent f203ca88de
commit 43fd2201a0
223 changed files with 36183 additions and 4115 deletions

View File

@@ -0,0 +1,80 @@
# airano-mcp-bridge — WordPress.org submission package
This folder is the **wp.org-bound** version of the companion plugin. It is
deliberately the small surface (≈15 routes — SEO, media uploads, capability
probe, cache/transient/site-health, audit-hook), **not** the full v2.18.x
internal build.
The internal build at [../airano-mcp-bridge/](../airano-mcp-bridge) carries
the admin namespace (plugins/themes/files/db/elementor/etc.) which is too
broad for wp.org's review bar and stays out of the public release.
## Layout
```
airano-mcp-bridge-wporg/
├── airano-mcp-bridge/ ← what goes in the zip
│ ├── airano-mcp-bridge.php
│ ├── readme.txt
│ └── README.md
├── tests/
│ ├── check.sh ← static checks (headers, escaping, routes, etc.)
│ └── test_permission_callback.php ← unit test for the new gate
└── airano-mcp-bridge.zip ← ready-to-upload bundle (built from the folder)
```
## Pre-submission checks
```bash
# 1. Static / heuristic checks (no WordPress runtime required)
bash wordpress-plugin/airano-mcp-bridge-wporg/tests/check.sh
# 2. Unit tests on the new permission_callback (PHP-only, no WP bootstrap)
php wordpress-plugin/airano-mcp-bridge-wporg/tests/test_permission_callback.php
```
Both must exit 0 before zipping or uploading.
## Building the zip
```bash
python - <<'PY'
import zipfile, os
src = "wordpress-plugin/airano-mcp-bridge-wporg/airano-mcp-bridge"
dst = "wordpress-plugin/airano-mcp-bridge-wporg/airano-mcp-bridge.zip"
if os.path.exists(dst): os.remove(dst)
with zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as z:
for root, _, files in os.walk(src):
for f in files:
full = os.path.join(root, f)
arc = os.path.relpath(full, "wordpress-plugin/airano-mcp-bridge-wporg").replace(os.sep, "/")
z.write(full, arc)
print("built:", dst, os.path.getsize(dst), "bytes")
PY
```
## Reply to the wp.org review email
Use the message in [SUBMISSION_REPLY.md](SUBMISSION_REPLY.md). Keep it
short — the wp.org guidance is "be brief and direct".
## What the v2.10.2 release fixes (vs the v2.10.1 they reviewed)
1. **Direct core file include**
`wp-admin/includes/media.php` was loaded inside the `/upload-chunk` and
`/upload-and-attach` REST callbacks but never used (the code only calls
helpers from `file.php` and `image.php`). The redundant `require_once`
lines are removed.
2. **`/upload-and-attach` permission gate**
The route now uses a dedicated `require_upload_and_attach_capability`
method that runs `current_user_can('edit_post', $attach_to_post)` at
the route gate when `attach_to_post` is supplied. The check is no
longer hidden inside the callback body, so static analysis can see it.
The method also rejects `set_featured` without a target post.
3. **Ownership verification**
DNS TXT record `wordpressorg-airano-verification` is published at
`mcp.example.com`, which is also the `Plugin URI`. This pairs the
submission with the domain the plugin advertises and satisfies the
wp.org review request for a non-gmail.com proof of identity.

View File

@@ -0,0 +1,95 @@
# Reply to wp.org review email — paste into the email thread
Keep this short and direct. wp.org explicitly asks reviewers to skim, not read essays.
---
## Subject line (current round — class-prefix fix)
Re: It's time to move forward with the plugin review "airano" — airano-mcp-bridge
(Reply in the existing thread; don't open a new one.)
---
## Body — paste this for the SECOND review (class prefix fix)
```
Hi,
Thanks. Addressed in v2.10.3:
- Main class renamed from generic `SEO_API_Bridge` to `Airano_MCP_Bridge`
to use the plugin-specific prefix (your "unique prefixes" guideline).
All other elements already used the `airano` prefix as you noted.
This is an internal refactor — the class is only instantiated from the
file's bootstrap line and is never referenced by callers, so there's no
public-API impact.
Updated zip uploaded via "Add your plugin" while logged in as airano.
Thanks,
airano
```
### Reply for the FIRST review (already sent — kept here for reference)
This was the response to the initial pre-review email about ownership
+ media.php/permission_callback. Already sent.
```
Hi,
Thanks for the review. Two notes on the requested items:
1) Ownership / domain verification
The plugin's URI is https://mcp.example.com (the public site for
MCP Hub, the project this companion plugin pairs with). I publish to
both mcp.example.com and example.com; my account on
wordpress.org is "airano".
I've published the requested DNS TXT record at the plugin's domain:
Host: mcp.example.com
Type: TXT
Value: wordpressorg-airano-verification
Please verify with `dig TXT mcp.example.com +short` (the value
should appear within the TTL).
I have also already published https://wordpress.org/plugins/airano-mcp-seo-bridge/
under the same airano account. That plugin is the SEO-only ancestor of
this one. Once airano-mcp-bridge is approved I will release a final
update there pointing users to the new slug.
2) Code review items
Both code points have been addressed in v2.10.2:
- The redundant `wp-admin/includes/media.php` requires inside
/upload-chunk and /upload-and-attach are removed. Those callbacks
only use helpers from file.php (wp_tempnam, wp_handle_sideload) and
image.php (wp_generate_attachment_metadata).
- /upload-and-attach now has its own permission_callback,
`require_upload_and_attach_capability`, which enforces
`current_user_can('edit_post', $attach_to_post)` at the route gate
when attach_to_post is supplied (no longer hidden inside the
callback body), and rejects set_featured without a target post.
I've uploaded the corrected v2.10.2 zip via "Add your plugin" while
logged in as airano.
Happy to make further changes if anything else turns up on a closer read.
Thanks,
airano
```
---
## Before sending
- [ ] You've uploaded `airano-mcp-bridge.zip` (v2.10.3) via the same "Add your plugin" page while logged in as `airano`.
- [ ] You're replying *in the existing thread* with the body shown above.

View File

@@ -0,0 +1,132 @@
# Releasing v2.10.3 to wordpress.org via SVN — DONE 2026-05-10
**Status (2026-05-10):**
- ✅ trunk committed at `r3527909` (`svn ci`-ed by maintainer)
- ✅ assets committed at `r3527917` (icon-128/256, banner-772x250 / 1544x500)
- ✅ tag `2.10.3` committed at `r3527918`
- ✅ legacy slug `airano-mcp-seo-bridge` updated to v1.3.1 at
`r3527927/r3527928` with migration notice pointing here
Live page: <https://wordpress.org/plugins/airano-mcp-bridge/>
The rest of this file is the original runbook, kept as reference for
future releases.
---
---
## What is already staged for you
A working copy is checked out at
`wordpress-plugin/airano-mcp-bridge-wporg-svn/` (gitignored). The three
plugin files have been copied into `trunk/` and `svn add`-ed:
```
A trunk/README.md
A trunk/airano-mcp-bridge.php
A trunk/readme.txt
```
The files are byte-identical to
`wordpress-plugin/airano-mcp-bridge-wporg/airano-mcp-bridge/`, which is
the version that just passed review.
---
## Step 1 — confirm credentials
If the approval email landed less than an hour ago, wait until the hour
is up. Then verify your SVN password at:
https://profiles.wordpress.org/me/profile/edit/group/3/?screen=svn-password
The SVN username is your wordpress.org login: `airano` (case-sensitive).
---
## Step 2 — commit the initial release to trunk
```bash
cd wordpress-plugin/airano-mcp-bridge-wporg-svn
svn ci -m "Initial release v2.10.3"
# → SVN will prompt for username (airano) and password the first time.
# Tick "store password" if you trust the machine.
```
Wait for the response. A successful commit ends with `Committed revision XXXXXXX.`
---
## Step 3 — tag the release
wp.org expects every released version to also exist as a tag under
`tags/<version>/`. The Stable Tag in `readme.txt` already points to
`2.10.3`, so this tag is what `wp.org` actually serves to users.
```bash
# Still inside wordpress-plugin/airano-mcp-bridge-wporg-svn/
svn cp trunk tags/2.10.3
svn ci -m "Tag 2.10.3"
```
After this, `https://wordpress.org/plugins/airano-mcp-bridge/` will start
serving v2.10.3 within a few minutes.
---
## Step 4 — verify on wp.org
- Plugin page: <https://wordpress.org/plugins/airano-mcp-bridge/>
- Latest tag: <https://plugins.svn.wordpress.org/airano-mcp-bridge/tags/2.10.3/>
- Trunk: <https://plugins.svn.wordpress.org/airano-mcp-bridge/trunk/>
The "Tested up to" badge, version number, and changelog should all show
2.10.3.
---
## Optional but recommended — plugin assets
Plugin assets (icon, banner, screenshots) live in the SVN `assets/`
directory at the same level as `trunk/` — NOT inside `trunk/assets/`.
Required filenames (any of):
```
assets/icon-128x128.png (or .jpg / icon.svg)
assets/icon-256x256.png (high-DPI)
assets/banner-772x250.png (header image, listing card)
assets/banner-1544x500.png (high-DPI header)
assets/screenshot-1.png (matched by readme.txt "== Screenshots ==" entries)
assets/screenshot-2.png
```
To upload after creating them locally:
```bash
cd wordpress-plugin/airano-mcp-bridge-wporg-svn
cp /path/to/icon-256x256.png assets/
cp /path/to/banner-1544x500.png assets/
svn add assets/icon-256x256.png assets/banner-1544x500.png
svn ci -m "Add plugin assets — icon + banner"
```
Spec: <https://developer.wordpress.org/plugins/wordpress-org/plugin-assets/>
---
## Optional — close the old slug
The old slug `airano-mcp-seo-bridge` (the SEO-only ancestor) is still
live. After v2.10.3 of the new slug is on the directory, you have two
options:
**(a) Push a final 1.x release to the old slug** that simply prints an
admin notice ("This plugin is now Airano MCP Bridge — please install
that and deactivate this one"). Less disruptive for existing users.
**(b) Email `plugins@wordpress.org`** asking to close the old slug as
"author request — superseded by airano-mcp-bridge". Faster.
Either is fine. Do this *after* the new slug is live and you've
confirmed at least one install path works.

Binary file not shown.

After

Width:  |  Height:  |  Size: 629 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -0,0 +1,193 @@
# Airano MCP Bridge
**Version:** 2.10.3
**Requires WordPress:** 5.0+
**Tested up to:** 6.9
**Requires PHP:** 7.4+
**License:** GPLv2 or later
**Plugin slug:** `airano-mcp-bridge` (permanent)
> Companion plugin for [MCP Hub](https://mcp.example.com) — the AI-native management hub for WordPress, WooCommerce, and self-hosted services. Source: [github.com/airano-ir/mcphub](https://github.com/airano-ir/mcphub).
## What this plugin does
MCP Hub lets AI assistants (Claude, ChatGPT, Cursor, VS Code Copilot, …) manage your WordPress site through the Model Context Protocol. The core features work against the stock `/wp-json/wp/v2/` REST endpoints — **you do not need this plugin to use MCP Hub**.
Installing **Airano MCP Bridge** unlocks a second tier of capabilities that WordPress's built-in REST API can't reach on its own:
- **Large-file uploads** that bypass `upload_max_filesize`
- **One-round-trip upload + metadata + attach + set-featured** (v2.9.0)
- **Thumbnail regeneration** (no WP-CLI / SSH)
- **Cache purge** for 6 major cache plugins (LiteSpeed, WP Rocket, W3TC, WP Super Cache, WP Fastest Cache, SG Optimizer)
- **Transient cleanup** (expired / all / pattern)
- **Bulk post-meta writes** in a single REST round-trip
- **Unified site-health snapshot** (5+ stock calls → 1)
- **Structured JSON export** (posts / pages / products + media + terms + meta)
- **Capability probe** — tells MCP Hub what the calling application password can actually do
- **Audit hook** — forwards WordPress action events to MCP Hub with HMAC-signed webhooks
- **SEO meta routes** for Rank Math and Yoast
All operations remain guarded by WordPress's own capability system — this plugin never bypasses auth.
## Installation
1. Download `airano-mcp-bridge.zip` from the MCP Hub release page.
2. In WP Admin → **Plugins → Add New → Upload Plugin**, choose the zip and **Activate**.
3. Generate an **Application Password** under Users → Profile (scroll to bottom).
4. In MCP Hub dashboard → **My Sites**, add your WordPress site using that Application Password.
The plugin works out of the box. There are no settings pages to configure; behavior is controlled entirely through the REST API.
## REST API endpoints
### SEO meta (namespace `airano-mcp-bridge/v1`)
- `GET / POST /posts/{id}/seo` — Post SEO data (Rank Math or Yoast, auto-detected)
- `GET / POST /pages/{id}/seo` — Page SEO data
- `GET / POST /products/{id}/seo` — WooCommerce product SEO data
- `GET /status` — Plugin status + active SEO plugins list
### MCP Bridge helpers (namespace `airano-mcp/v1`)
| Route | Method | Introduced | Purpose |
|---|---|---|---|
| `/upload-limits` | GET | 2.0.0 | Returns effective PHP + WP upload limits so MCP Hub can pick the right upload path |
| `/upload-chunk` | POST | 2.0.0 | Raw-body upload that bypasses `upload_max_filesize` (still bounded by `post_max_size`) |
| `/upload-and-attach` | POST | 2.9.0 | Same as `/upload-chunk` plus query-param metadata (`attach_to_post`, `set_featured`, `title`, `alt_text`, `caption`, `description`) — collapses 3 REST round-trips into 1 |
| `/capabilities` | GET | 2.1.0 | Effective capabilities of the calling Application Password + feature flags + available routes |
| `/bulk-meta` | POST | 2.2.0 | Batch `post_meta` writes in one request (max 500 items, per-item permission check) |
| `/export` | GET | 2.3.0 | Structured JSON export of posts / pages / products + media + terms + meta, with `post_type` / `status` / `since` / `limit` / `offset` paging |
| `/cache-purge` | POST | 2.4.0 | Auto-detects LiteSpeed, WP Rocket, W3 Total Cache, WP Super Cache, WP Fastest Cache, SiteGround Optimizer and triggers each one's purge API; always flushes object cache |
| `/transient-flush` | POST | 2.5.0 | Native transient cleanup with `expired` / `all` / `pattern` scopes; optional site-transient handling for multisite |
| `/site-health` | GET | 2.6.0 | Single-envelope health snapshot: WP/PHP/MySQL versions, disk free, active plugins, theme, writability, SSL — replaces 5+ stock calls |
| `/audit-hook` | GET / POST / DELETE | 2.7.0 | Configure a webhook that forwards WP action events to MCP Hub. HMAC-SHA256-signed, non-blocking |
| `/regenerate-thumbnails` | POST | 2.8.0 | Rebuild attachment sub-sizes via `wp_generate_attachment_metadata()` for a list of IDs or in paged batch mode |
### Authentication
All routes require **HTTP Basic** authentication with a WordPress **Application Password** (Users → Profile → Application Passwords). Per-route capability requirements:
| Route family | Required capability |
|---|---|
| SEO meta (read/write) | `edit_posts` |
| Upload helpers | `upload_files` or `manage_options` |
| Capabilities probe | `read` (any logged-in user) |
| Bulk meta, Export | `edit_posts` (per-item `edit_post` check) |
| Cache purge, Transient flush, Site health, Audit hook, Regenerate thumbnails | `manage_options` |
## Privacy
This plugin makes **no outbound network calls** on its own. It exposes authenticated REST API endpoints that your MCP Hub instance calls into. The audit-hook route **only** sends webhooks when you explicitly configure it (POST to `/audit-hook` with an endpoint URL + secret); without that configuration, zero telemetry leaves your WordPress.
No analytics, no phone-home, no cloud relay.
## Security notes
- All write routes enforce capability checks.
- Raw-body uploads go through `wp_handle_sideload()` which respects WordPress's allowed-MIME list.
- Bulk meta writes check `current_user_can('edit_post', $post_id)` per item, not just per batch.
- Audit-hook secrets are stored in `wp_options` and never echoed in responses (only the last 4 characters are returned on read).
- Audit-hook webhooks are signed with HMAC-SHA256 so MCP Hub can verify the message came from your site and hasn't been tampered with.
If you find a security issue, please report it privately to the maintainers at the [GitHub issues page](https://github.com/airano-ir/mcphub/issues) rather than filing a public issue.
## Frequently asked questions
### Do I need MCP Hub to use this plugin?
No. Every route is a standard authenticated WordPress REST endpoint and can be called from any HTTP client (curl, Postman, a custom integration). MCP Hub is the reference consumer but not a requirement.
### Which SEO plugins are supported?
**Rank Math SEO** and **Yoast SEO**. The plugin auto-detects which is active and routes SEO-meta calls accordingly.
### Does this plugin work on multisite?
Yes. Route registration, capability checks, and transient flushing all respect multisite boundaries. The optional `include_site_transients` flag on `/transient-flush` handles site transients on multisite setups.
### Why keep the folder name `airano-mcp-bridge` even though the plugin now does much more than SEO?
Plugin slugs on WordPress.org are permanent once registered. Existing installs keep working; display name, description, and feature surface are what we can update.
### What happens if I uninstall?
Uninstall removes plugin options including the audit-hook secret and endpoint. Media uploaded via the plugin stays in your media library (it's regular WordPress attachments — the plugin just provides the upload pipeline).
### How large a file can I upload?
The `/upload-chunk` route bypasses PHP's `upload_max_filesize` (typically 264 MB on shared hosting), but is still bounded by `post_max_size` and `memory_limit`. For files larger than `post_max_size`, MCP Hub falls back to its own server-side chunked session pipeline that reassembles chunks before handing the full file to this route.
## Changelog
### 2.9.0 — 2026-04-16
- New: `POST /airano-mcp/v1/upload-and-attach` — raw-body upload + metadata (title / alt / caption / description) + attach-to-post + set-featured in a single REST round-trip. Saves 23 calls for every hero-image or product-image workflow. Per-target permission enforced via `current_user_can('edit_post', attach_to_post)`.
- Status + capabilities payloads now advertise `upload_and_attach: true` + `regenerate_thumbnails: true`.
### 2.8.0 — 2026-04-15
- New: `POST /airano-mcp/v1/regenerate-thumbnails` — rebuild attachment sub-sizes via `wp_generate_attachment_metadata()` after uploads, theme switches, or format conversions. Two modes: `{ "ids": [...] }` for targeted regeneration (max 50/call) or `{ "all": true, "offset": N, "limit": M }` for paged batches.
### 2.7.0
- New: `GET|POST|DELETE /airano-mcp/v1/audit-hook` — configure a webhook that forwards WP action events (`transition_post_status`, `deleted_post`, `user_register`, `profile_update`, `deleted_user`, `activated_plugin`, `deactivated_plugin`, `switch_theme`) to MCP Hub. HMAC-SHA256-signed, non-blocking `wp_remote_post`. Permission: `manage_options`.
### 2.6.0
- New: `GET /airano-mcp/v1/site-health` — single-envelope snapshot: WP version / multisite / locale, PHP version + extensions, server software + disk free, MySQL/MariaDB version + charset, active plugins + theme, writability checks (wp-content, uploads) + SSL.
### 2.5.0
- New: `POST /airano-mcp/v1/transient-flush` — native transient cleanup. Scopes: `expired` (default), `all`, or `pattern` (glob match, e.g. `rank_math_*`). Optional `include_site_transients` for multisite.
### 2.4.0
- New: `POST /airano-mcp/v1/cache-purge` — auto-detects active page-cache plugins (LiteSpeed, WP Rocket, W3 Total Cache, WP Super Cache, WP Fastest Cache, SiteGround Optimizer) and invokes each one's purge API. Always calls `wp_cache_flush()` for object caches.
### 2.3.0
- New: `GET /airano-mcp/v1/export` — structured JSON export (not WXR). Query params: `post_type`, `status`, `since`, `limit` (max 500), `offset`, `include_media`, `include_terms`, `include_meta`.
### 2.2.0
- New: `POST /airano-mcp/v1/bulk-meta` — batch post-meta writes (max 500 items per call). Per-item permission check via `current_user_can('edit_post', $post_id)`. Null meta values delete the key.
### 2.1.0
- New: `GET /airano-mcp/v1/capabilities` — reports the effective capabilities of the calling Application Password plus feature flags (Rank Math / Yoast / WooCommerce / multisite) and the list of companion routes this version ships. Consumed by MCP Hub's F.7e capability probe.
### 2.0.0
- **Rebrand**: "Airano MCP SEO Meta Bridge" → "Airano MCP Bridge". No longer SEO-only.
- New: `GET /airano-mcp/v1/upload-limits` — returns effective PHP + WP upload limits.
- New: `POST /airano-mcp/v1/upload-chunk` — raw-body upload route that bypasses `upload_max_filesize`.
### 1.3.0
- Added REST API endpoints for posts, pages, and products (GET/POST operations).
- Added authentication requirement for all endpoints (edit_posts).
- Fixed `rank_math_title` meta key registration.
### 1.2.0
- Enhanced WooCommerce product support.
- Improved MariaDB compatibility.
### 1.1.0
- Added status endpoint for plugin detection.
- Improved error handling.
### 1.0.0
- Initial release with Rank Math and Yoast SEO support.
## Upgrade notice
### 2.9.0
Adds `POST /airano-mcp/v1/upload-and-attach` — upload + metadata + attach + featured in one REST round-trip. No breaking changes to existing routes.
## Credits
Built by the [MCP Hub](https://github.com/airano-ir/mcphub) project. Contributions welcome — see the parent repository for contribution guidelines.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,207 @@
=== Airano MCP Bridge ===
Contributors: airano
Tags: mcp, ai, rest-api, seo, media
Requires at least: 5.0
Tested up to: 6.9
Requires PHP: 7.4
Stable tag: 2.10.3
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html
Companion plugin for MCP Hub. REST API routes for SEO meta and raw-binary media uploads that bypass upload_max_filesize.
== Description ==
**Airano MCP Bridge** is the official companion plugin for [MCP Hub](https://mcp.palebluedot.live) ([source](https://github.com/airano-ir/mcphub)) — the AI-native management hub for WordPress, WooCommerce, and self-hosted services. It extends the WordPress REST API with dedicated routes that AI agents (Claude, ChatGPT, Cursor, VS Code Copilot) and the MCP Hub server rely on:
* **SEO meta routes** — read and write Rank Math SEO and Yoast SEO metadata for posts, pages, and WooCommerce products.
* **Media upload helper routes** — accept raw-binary uploads via `php://input`, which bypasses the `upload_max_filesize` PHP limit (still bounded by `post_max_size`). Unlocks reliable hero-image and large-asset uploads for AI-agent workflows.
* **Capability probe** — reports the effective PHP + WordPress upload limits so MCP Hub can pick the right upload path automatically.
**Features:**
* **Rank Math SEO Support** — Full access to all Rank Math meta fields
* **Yoast SEO Support** — Full access to all Yoast SEO meta fields
* **WooCommerce Compatible** — Works with product post types
* **MCP Hub media pipeline** — bypasses `upload_max_filesize` for AI-agent uploads
* **Secure** — Requires WordPress Application Password + capability checks on every route
* **Auto-Detection** — Automatically detects active SEO plugin and advertises capabilities
* **Zero Configuration** — Works out of the box after activation
**REST API Endpoints:**
*SEO meta (namespace `airano-mcp-bridge/v1`)*
* `GET/POST /wp-json/airano-mcp-bridge/v1/posts/{id}/seo` — Post SEO data
* `GET/POST /wp-json/airano-mcp-bridge/v1/pages/{id}/seo` — Page SEO data
* `GET/POST /wp-json/airano-mcp-bridge/v1/products/{id}/seo` — Product SEO data (WooCommerce)
* `GET /wp-json/airano-mcp-bridge/v1/status` — Plugin status + active SEO plugins
*MCP Bridge helpers (namespace `airano-mcp/v1`, added in 2.0.0)*
* `GET /wp-json/airano-mcp/v1/upload-limits` — Effective PHP + WP upload limits
* `POST /wp-json/airano-mcp/v1/upload-chunk` — Raw-body media upload (bypasses `upload_max_filesize`)
* `GET /wp-json/airano-mcp/v1/capabilities` — Effective capabilities + feature flags + available routes (added in 2.1.0)
* `POST /wp-json/airano-mcp/v1/bulk-meta` — Batch post/product meta writes in a single HTTP round-trip (added in 2.2.0)
* `GET /wp-json/airano-mcp/v1/export` — Structured JSON export: posts, pages, products + media + terms + meta, with post_type/status/since/limit/offset paging (added in 2.3.0)
* `POST /wp-json/airano-mcp/v1/cache-purge` — Auto-detects active cache plugins (LiteSpeed, WP Rocket, W3TC, Super Cache, Fastest Cache, SG Optimizer) and invokes their purge API; always flushes object cache (added in 2.4.0)
* `POST /wp-json/airano-mcp/v1/transient-flush` — Native transient cleanup with scope=expired/all/pattern (glob); optional site-transient handling on multisite (added in 2.5.0)
* `GET /wp-json/airano-mcp/v1/site-health` — Unified site-health snapshot: PHP/MySQL/WP versions, extensions, disk free, active plugins + theme, writability checks (added in 2.6.0)
* `GET|POST|DELETE /wp-json/airano-mcp/v1/audit-hook` — Configure + query a webhook that forwards WordPress action events to MCP Hub (HMAC-SHA256 signed, non-blocking) (added in 2.7.0)
* `POST /wp-json/airano-mcp/v1/regenerate-thumbnails` — Regenerate attachment sub-sizes via `wp_generate_attachment_metadata` for a list of IDs or in paged batch mode (added in 2.8.0)
* `POST /wp-json/airano-mcp/v1/upload-and-attach` — Raw-body upload + metadata + attach-to-post + set-featured in a single REST call (added in 2.9.0). Query params: `attach_to_post`, `set_featured`, `title`, `alt_text`, `caption`, `description`. Permission: `upload_files` + `edit_post` on the target post.
**Designed for [MCP Hub](https://mcp.palebluedot.live)** — the AI-native management hub for WordPress and self-hosted services. [Try the live platform](https://mcp.palebluedot.live) · [Source on GitHub](https://github.com/airano-ir/mcphub).
== Installation ==
1. Upload the `airano-mcp-bridge` folder to `/wp-content/plugins/`
2. Activate the plugin through the 'Plugins' menu in WordPress
3. For SEO meta routes: ensure either Rank Math SEO or Yoast SEO is active
4. The REST API endpoints are now available
== Frequently Asked Questions ==
= Which SEO plugins are supported? =
Rank Math SEO and Yoast SEO. The plugin auto-detects which one is active.
= Does it work with WooCommerce? =
Yes. Product SEO endpoints are available when WooCommerce is active.
= How is authentication handled? =
All endpoints require WordPress Application Password authentication. SEO routes require `edit_posts`. Upload helper routes require `upload_files` or `manage_options`.
= Do I need MCP Hub to use the upload helper routes? =
No. The `/airano-mcp/v1/upload-chunk` route is a standard authenticated REST endpoint and can be used from any client that can send a raw-binary `POST` with `Content-Disposition` and `Content-Type` headers.
= Is there a size cap on the upload helper? =
The helper reads the request body via `php://input`, which is **not** subject to PHP's `upload_max_filesize` limit — but it is still bounded by `post_max_size` and `memory_limit`. For files larger than `post_max_size`, MCP Hub falls back to its own server-side chunked session pipeline.
= Why keep the folder name "airano-mcp-bridge" even though the plugin now does more than SEO? =
Plugin slugs on wordpress.org are permanent. Existing installs keep working; the display name and feature set are updated in 2.0.0.
= Can I use this without MCP Hub? =
Yes. All REST API endpoints work with any application that can make authenticated HTTP requests.
== Changelog ==
= 2.10.3 =
* WordPress.org review fix: rename main class from the generic `SEO_API_Bridge` to `Airano_MCP_Bridge` per the wp.org "unique prefixes" guideline. The class is only instantiated from this file's bootstrap line and is never referenced by callers (the plugin's public surface is its REST routes, not its PHP class), so this is an internal refactor with no behaviour change.
= 2.10.2 =
* WordPress.org review fixes:
* Drop the unused `wp-admin/includes/media.php` includes from `/upload-chunk` and `/upload-and-attach`. The code only uses helpers from `file.php` (`wp_tempnam`, `wp_handle_sideload`) and `image.php` (`wp_generate_attachment_metadata`) — `media.php` was a needless direct core include.
* Split `/upload-and-attach` permission_callback into its own method (`require_upload_and_attach_capability`) that explicitly enforces `current_user_can('edit_post', $attach_to_post)` at the route gate when `attach_to_post` is supplied (no longer hidden inside the callback body), and rejects `set_featured` requests that don't include a target post.
* No behaviour changes for clients sending well-formed requests.
= 2.10.1 =
* Fix: `capabilities` and `site-health` routes now share a single source of truth for the `routes` bitmap (previously duplicated with conflicting values for `audit_hook` and missing `upload_and_attach`). Both endpoints now read from the shared route-map constant.
= 2.10.0 =
* `POST /wp-json/airano-mcp/v1/upload-and-attach` now honours an `Idempotency-Key` request header. A retry within 24 hours with the same key returns the already-created attachment (`_idempotent_replay: true`) rather than creating a duplicate `-2.webp` orphan. Protects against client-timeout regressions where a successful upload was retried because the response was lost.
= 2.9.0 =
* Added `POST /wp-json/airano-mcp/v1/upload-and-attach` — same raw-body semantics as `/upload-chunk`, but accepts query params (`attach_to_post`, `set_featured`, `title`, `alt_text`, `caption`, `description`) and applies them in one PHP round-trip. Saves 2-3 REST calls per upload. Per-target permission enforced via `current_user_can('edit_post', attach_to_post)`.
* `status` + `capabilities` routes now advertise `upload_and_attach: true` alongside the existing capability flags.
= 2.8.0 =
* Added `POST /wp-json/airano-mcp/v1/regenerate-thumbnails` — rebuild attachment sub-sizes via `wp_generate_attachment_metadata()` after an upload or format conversion changes source pixels. Supports two modes: `{ "ids": [...] }` for targeted regeneration (up to 50 per call) and `{ "all": true, "offset": N, "limit": M }` for paged batches (max 50 per page, with `has_more` + `next_offset` for pagination). Non-image attachments are skipped; per-item permission check via `current_user_can('edit_post', $attachment_id)`. Permission: `upload_files` or `manage_options`.
= 2.7.0 =
* Added `GET|POST|DELETE /wp-json/airano-mcp/v1/audit-hook` — configure a webhook that forwards WordPress action events (post transitions, deletions, user events, plugin activations, theme switches) to MCP Hub for unified auditing. Each event is signed with HMAC-SHA256 using a shared secret and posted non-blocking via `wp_remote_post` so the admin UI stays snappy. Secret is stored in `wp_options` and never echoed back in GET responses (only the last 4 characters are returned). Permission: `manage_options`.
= 2.6.0 =
* Added `GET /wp-json/airano-mcp/v1/site-health` — single-envelope health snapshot: WordPress version/multisite/locale, PHP version + critical extensions, server software + disk free, MySQL/MariaDB version + charset, active plugins list (with versions), active + parent theme, writability checks (wp-content, uploads) and SSL status. Replaces 5+ separate REST calls with a single request. Permission: `manage_options`.
= 2.5.0 =
* Added `POST /wp-json/airano-mcp/v1/transient-flush` — native PHP transient cleanup. Scopes: `expired` (default, calls `delete_expired_transients()`), `all` (delete every `_transient_%` row), or `pattern` (glob match, e.g. `rank_math_*`). Optional `include_site_transients` for multisite. Response includes `deleted_count` + capped `deleted_sample` for observability. Permission: `manage_options`.
= 2.4.0 =
* Added `POST /wp-json/airano-mcp/v1/cache-purge` — auto-detects active page-cache plugins (LiteSpeed Cache, WP Rocket, W3 Total Cache, WP Super Cache, WP Fastest Cache, SiteGround Optimizer) and triggers their purge API. Always calls `wp_cache_flush()` for object caches. Permission: `manage_options`. Removes the need for Docker-socket + WP-CLI to flush caches on managed hosts.
= 2.3.0 =
* Added `GET /wp-json/airano-mcp/v1/export` — structured JSON export of posts, pages, and WooCommerce products (not WXR). Query params: `post_type`, `status`, `since`, `limit` (max 500), `offset`, `include_media`, `include_terms`, `include_meta`. Response includes posts + referenced media + taxonomy terms + post meta in a single envelope, plus `has_more` / `next_offset` for pagination. Permission: `edit_posts`.
= 2.2.0 =
* Added `POST /wp-json/airano-mcp/v1/bulk-meta` — writes many `post_meta` / `product_meta` entries in a single REST request. Capped at 500 items per call; each item is permission-checked via `current_user_can('edit_post', $post_id)`. Null meta values delete the key. Status endpoint now advertises `capabilities.bulk_meta = true` and the capabilities probe reports `routes.bulk_meta = true`.
= 2.1.0 =
* Added `GET /wp-json/airano-mcp/v1/capabilities` — reports the effective capabilities of the calling application password, plus feature flags (Rank Math / Yoast / WooCommerce / multisite) and the list of companion routes this version actually ships. Consumed by MCP Hub's capability probe so per-user clients only see tools they're actually authorised to use.
* Status endpoint now advertises `capabilities.capabilities = true`.
= 2.0.0 =
* **Rebrand**: "Airano MCP SEO Meta Bridge" → "Airano MCP Bridge". The companion plugin is no longer SEO-only.
* Added `GET /wp-json/airano-mcp/v1/upload-limits` — returns effective PHP + WP upload limits for MCP Hub probes.
* Added `POST /wp-json/airano-mcp/v1/upload-chunk` — raw-body upload route that bypasses `upload_max_filesize`.
* Status endpoint now includes `capabilities: { seo_meta, upload_limits, upload_chunk }`.
* Plugin folder / slug unchanged: `airano-mcp-bridge` (permanent on wordpress.org).
= 1.3.0 =
* Added REST API endpoints for posts, pages, and products (GET/POST operations)
* Added authentication requirement for all endpoints
* Fixed rank_math_title meta key registration
= 1.2.0 =
* Enhanced WooCommerce product support
* Improved MariaDB compatibility
= 1.1.0 =
* Added status endpoint for plugin detection
* Improved error handling
= 1.0.0 =
* Initial release with Rank Math and Yoast SEO support
== Upgrade Notice ==
= 2.10.3 =
Internal refactor only — main class renamed from `SEO_API_Bridge` to `Airano_MCP_Bridge` to satisfy wp.org's prefix guideline. No behaviour or REST API changes.
= 2.10.2 =
WordPress.org review fixes only — no behaviour changes. Drops a redundant core file include and tightens the `/upload-and-attach` permission gate so the per-target `edit_post` check runs before the callback (visible to static analysis).
= 2.10.1 =
Internal fix: capabilities and site-health now share a single route map. No breaking changes.
= 2.10.0 =
`/upload-and-attach` now supports `Idempotency-Key` header to safely retry uploads after client timeouts without creating duplicates. No breaking changes.
= 2.9.0 =
Adds `POST /airano-mcp/v1/upload-and-attach` — upload + metadata + attach + featured in one REST round-trip. No breaking changes.
= 2.8.0 =
Adds `POST /airano-mcp/v1/regenerate-thumbnails` for rebuilding attachment sub-sizes after uploads or format changes. No breaking changes.
= 2.7.0 =
Adds `GET|POST|DELETE /airano-mcp/v1/audit-hook` to forward WordPress action events to MCP Hub. Disabled until configured. No breaking changes.
= 2.6.0 =
Adds `GET /airano-mcp/v1/site-health` for a unified site-health snapshot. No breaking changes.
= 2.5.0 =
Adds `POST /airano-mcp/v1/transient-flush` for native transient cleanup. No breaking changes.
= 2.4.0 =
Adds `POST /airano-mcp/v1/cache-purge` for native cache flushing. No breaking changes.
= 2.3.0 =
Adds `GET /airano-mcp/v1/export` for offline backups and migrations. No breaking changes.
= 2.2.0 =
Adds `POST /airano-mcp/v1/bulk-meta` so MCP Hub can write many post meta values in one request. No breaking changes.
= 2.1.0 =
Adds `GET /airano-mcp/v1/capabilities` so MCP Hub can probe which tools the current application password can actually use. No breaking changes; existing routes unchanged.
= 2.0.0 =
Rebranded to "Airano MCP Bridge". Adds new `airano-mcp/v1` namespace with upload-helper routes. All existing 1.x REST endpoints keep working unchanged.
= 1.3.0 =
GET endpoints now require authentication (edit_posts capability). Update your API clients if needed.

View File

@@ -0,0 +1,170 @@
#!/usr/bin/env bash
# Pre-submission checks for airano-mcp-bridge (wp.org-bound version).
#
# Run from the repo root or from this directory:
# bash wordpress-plugin/airano-mcp-bridge-wporg/tests/check.sh
#
# Exit code is non-zero on any FAIL. Prints a one-line summary at the end.
set -u
PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/airano-mcp-bridge"
PHP_FILE="${PLUGIN_DIR}/airano-mcp-bridge.php"
README_TXT="${PLUGIN_DIR}/readme.txt"
fail=0
pass() { printf ' \033[32mPASS\033[0m %s\n' "$1"; }
fail() { printf ' \033[31mFAIL\033[0m %s\n' "$1"; fail=$((fail+1)); }
section() { printf '\n\033[1m%s\033[0m\n' "$1"; }
[ -f "$PHP_FILE" ] || { echo "main PHP file not found: $PHP_FILE"; exit 2; }
[ -f "$README_TXT" ] || { echo "readme.txt not found: $README_TXT"; exit 2; }
section "1. PHP syntax (php -l)"
if php -l "$PHP_FILE" > /tmp/phplint.out 2>&1; then
pass "syntax clean"
else
fail "php -l reported errors:"
cat /tmp/phplint.out
fi
section "2. Plugin headers"
ver_php=$(grep -oE '^\s*\*\s*Version:\s*\S+' "$PHP_FILE" | head -1 | awk '{print $NF}')
ver_const=$(grep -oE "const VERSION\s*=\s*'[^']+'" "$PHP_FILE" | head -1 | grep -oE "'[^']+'" | tr -d "'")
stable=$(grep -oE '^Stable tag:\s*\S+' "$README_TXT" | awk '{print $NF}')
tested=$(grep -oE '^Tested up to:\s*\S+' "$README_TXT" | awk '{print $NF}')
plugin_uri=$(grep -oE '^\s*\*\s*Plugin URI:\s*\S+' "$PHP_FILE" | head -1 | awk '{print $NF}')
author_uri=$(grep -oE '^\s*\*\s*Author URI:\s*\S+' "$PHP_FILE" | head -1 | awk '{print $NF}')
[ "$ver_php" = "$ver_const" ] && pass "Version header == const VERSION ($ver_php)" || fail "Version mismatch: header=$ver_php const=$ver_const"
[ "$ver_php" = "$stable" ] && pass "Version header == readme.txt Stable tag ($ver_php)" || fail "Stable tag mismatch: $ver_php vs $stable"
case "$tested" in
*.*.*) fail "Tested up to has minor version ($tested) — wp.org rejects this. Use major.minor only." ;;
*.*) pass "Tested up to is major.minor ($tested)" ;;
*) fail "Tested up to has unexpected format: $tested" ;;
esac
[ -n "$plugin_uri" ] && [ -n "$author_uri" ] && [ "$plugin_uri" != "$author_uri" ] \
&& pass "Plugin URI ($plugin_uri) and Author URI ($author_uri) are distinct" \
|| fail "Plugin URI and Author URI are the same or missing"
section "3. wp.org review issues from review email"
# Class name must use the Airano_MCP_ prefix (no generic SEO_API_Bridge)
if grep -q "^class SEO_API_Bridge" "$PHP_FILE"; then
fail "main class still named SEO_API_Bridge — wp.org rejects generic prefixes"
else
pass "main class is not the generic SEO_API_Bridge"
fi
if grep -q "^class Airano_MCP_Bridge" "$PHP_FILE"; then
pass "main class uses the Airano_MCP_ prefix"
else
fail "main class is not Airano_MCP_Bridge"
fi
# (a) media.php must NOT be loaded directly outside of the comment/changelog
includes=$(grep -nE "^\s*require(_once)?\s*\(?\s*ABSPATH\s*\.\s*['\"]wp-admin/includes/media\.php" "$PHP_FILE" | wc -l)
if [ "$includes" -eq 0 ]; then
pass "no direct require of wp-admin/includes/media.php"
else
fail "found $includes direct require(_once) of wp-admin/includes/media.php"
grep -nE "^\s*require(_once)?\s*\(?\s*ABSPATH\s*\.\s*['\"]wp-admin/includes/media\.php" "$PHP_FILE"
fi
# (b) /upload-and-attach must use the new dedicated permission_callback
if grep -q "'permission_callback' => \[\$this, 'require_upload_and_attach_capability'\]" "$PHP_FILE"; then
pass "/upload-and-attach is gated by require_upload_and_attach_capability"
else
fail "/upload-and-attach does not use require_upload_and_attach_capability"
fi
if grep -q "function require_upload_and_attach_capability" "$PHP_FILE"; then
pass "require_upload_and_attach_capability method is defined"
else
fail "require_upload_and_attach_capability method is missing"
fi
# It must call current_user_can('edit_post', $attach_to_post)
if grep -q "current_user_can('edit_post', \$attach_to_post)" "$PHP_FILE"; then
pass "edit_post per-target check is enforced at the route gate"
else
fail "edit_post per-target check is missing"
fi
section "4. REST routes — every register_rest_route has a permission_callback"
# Walk the file and check each register_rest_route(...) block individually.
# A block runs from its register_rest_route line up to the matching closing
# `]);` at the same brace depth. We check each block contains
# `permission_callback`.
audit=$(php -r '
$src = file_get_contents($argv[1]);
$lines = preg_split("/\r?\n/", $src);
$total = 0; $missing = 0; $missing_lines = [];
$inBlock = false; $depth = 0; $hasPerm = false; $startLine = 0;
foreach ($lines as $i => $line) {
$lineNo = $i + 1;
if (!$inBlock && preg_match("/register_rest_route\s*\(/", $line)) {
$inBlock = true; $depth = 0; $hasPerm = false; $startLine = $lineNo; $total++;
}
if ($inBlock) {
if (strpos($line, "permission_callback") !== false) $hasPerm = true;
$depth += substr_count($line, "[") + substr_count($line, "(");
$depth -= substr_count($line, "]") + substr_count($line, ")");
if ($depth <= 0 && $lineNo > $startLine) {
if (!$hasPerm) { $missing++; $missing_lines[] = $startLine; }
$inBlock = false;
}
}
}
echo "$total $missing " . implode(",", $missing_lines);
' "$PHP_FILE")
total=$(echo "$audit" | awk '{print $1}')
missing=$(echo "$audit" | awk '{print $2}')
missing_lines=$(echo "$audit" | awk '{print $3}')
if [ "$missing" -eq 0 ]; then
pass "all $total REST routes have a permission_callback"
else
fail "$missing of $total REST routes are missing permission_callback (lines: $missing_lines)"
fi
# No __return_true on routes that change state (POST/PUT/DELETE)
public_writes=$(awk '/register_rest_route/,/\]\s*\)\s*;/' "$PHP_FILE" \
| grep -B2 "__return_true" \
| grep -E "'methods'\s*=>\s*'(POST|PUT|DELETE|PATCH)'" \
| wc -l)
if [ "$public_writes" -eq 0 ]; then
pass "no public (__return_true) callbacks on POST/PUT/DELETE routes"
else
fail "$public_writes write routes use __return_true (public) — review needed"
fi
section "5. Output escaping & sanitisation (heuristic — manual review still required)"
# Find raw echo/print of REST request params
raw_echo=$(grep -nE "echo\s+\\\$request->|print\s+\\\$request->" "$PHP_FILE" | wc -l)
[ "$raw_echo" -eq 0 ] && pass "no raw echo of \$request-> values" || fail "$raw_echo raw echoes of \$request->"
# Reject sql concatenation patterns
sql_concat=$(grep -nE '\$wpdb->(query|get_results|get_var|get_row).*\.\s*\$' "$PHP_FILE" | wc -l)
[ "$sql_concat" -eq 0 ] && pass "no obvious SQL concatenation (no \$wpdb-> ... . \$var)" || fail "$sql_concat possible SQL concatenations — manual review"
# eval / system / exec must NOT appear
dangerous=$(grep -nE "\b(eval|exec|system|passthru|popen|shell_exec|proc_open)\s*\(" "$PHP_FILE" | grep -v "^\s*\*" | wc -l)
[ "$dangerous" -eq 0 ] && pass "no eval/exec/system/passthru/popen calls" || fail "$dangerous dangerous function calls"
section "6. Text domain"
td_count=$(grep -cE "(__|_e|_x|_n|esc_html__|esc_attr__|esc_html_e|esc_attr_e)\(\s*['\"]" "$PHP_FILE")
non_plugin_td=$(grep -oE "(__|_e|_x|_n|esc_html__|esc_attr__|esc_html_e|esc_attr_e)\(\s*['\"][^'\"]*['\"]\s*,\s*['\"][^'\"]+['\"]" "$PHP_FILE" \
| grep -oE "['\"][^'\"]+['\"]\s*\)\s*$" | grep -vc "airano-mcp-bridge" || true)
if [ "$non_plugin_td" -eq 0 ]; then
pass "$td_count translation calls all use 'airano-mcp-bridge' domain"
else
fail "$non_plugin_td translation calls use a non-plugin text domain"
fi
section "7. Direct file access guard"
if grep -q "if (!defined('ABSPATH'))" "$PHP_FILE" && grep -q "exit;" "$PHP_FILE"; then
pass "ABSPATH guard present"
else
fail "missing 'if (!defined(ABSPATH)) exit;' guard"
fi
section "Summary"
if [ "$fail" -eq 0 ]; then
printf '\033[32m✓ all checks passed\033[0m — version %s ready for wp.org review reply\n' "$ver_php"
exit 0
else
printf '\033[31m✗ %d check(s) failed\033[0m\n' "$fail"
exit 1
fi

View File

@@ -0,0 +1,183 @@
<?php
/**
* Standalone tests for require_upload_and_attach_capability without a
* full WordPress bootstrap. Stubs the few WP globals/functions the
* permission method touches and walks every gate.
*
* Run from the repo root:
* php wordpress-plugin/airano-mcp-bridge-wporg/tests/test_permission_callback.php
*
* Exit code is non-zero on any FAIL.
*/
// --- Minimal WP shims ----------------------------------------------------
if (!class_exists('WP_Error')) {
class WP_Error {
public $code; public $message; public $data;
public function __construct($code = '', $message = '', $data = '') {
$this->code = $code; $this->message = $message; $this->data = $data;
}
}
}
if (!function_exists('rest_authorization_required_code')) {
function rest_authorization_required_code() { return 401; }
}
// __() / esc_html__ / etc. just return the string unchanged.
foreach (['__', '_e', '_x', 'esc_html__', 'esc_attr__', 'esc_html_e', 'esc_attr_e'] as $fn) {
if (!function_exists($fn)) {
eval("function $fn(\$s, \$d = '') { return \$s; }");
}
}
// Capability + post-edit lookup is driven by globals so each test can
// configure the scenario.
$GLOBALS['__caps'] = []; // ['upload_files' => true, 'manage_options' => false, ...]
$GLOBALS['__edits'] = []; // [42 => true, 99 => false]
if (!function_exists('current_user_can')) {
function current_user_can($cap, $object_id = null) {
if ($cap === 'edit_post' && $object_id !== null) {
return !empty($GLOBALS['__edits'][$object_id]);
}
return !empty($GLOBALS['__caps'][$cap]);
}
}
// Tiny WP_REST_Request stub — only ->get_param() is used by the gate.
if (!class_exists('WP_REST_Request')) {
class WP_REST_Request {
private $params;
public function __construct(array $params = []) { $this->params = $params; }
public function get_param($k) { return $this->params[$k] ?? null; }
}
}
// --- Load just the Airano_MCP_Bridge class -------------------------------
// We can't require the whole plugin (it calls add_action()/add_filter() in
// the global scope on load), so we extract the class body and eval it.
// Strip the two add_action() lines at the bottom.
$src = file_get_contents(__DIR__ . '/../airano-mcp-bridge/airano-mcp-bridge.php');
$src = preg_replace('/<\?php/', '', $src, 1);
$src = preg_replace('/^.*?(if\s*\(!\s*defined.*?ABSPATH.*?\)\s*\{[^}]*\})/s', '', $src, 1);
// Remove any add_action / add_filter calls outside the class.
$src = preg_replace('/^\s*(add_action|add_filter)\s*\([^;]+;\s*$/m', '', $src);
// Remove `new Airano_MCP_Bridge();` invocation.
$src = preg_replace('/^\s*new\s+Airano_MCP_Bridge\s*\(\s*\)\s*;\s*$/m', '', $src);
// WP function shims so the class file can be loaded without bootstrapping
// WordPress. Each returns a benign default — the tests only exercise the
// permission method, not the rest of the plugin.
$_wp_voids = [
'add_action', 'add_filter', 'do_action', 'register_activation_hook',
'register_deactivation_hook', 'register_uninstall_hook',
'load_plugin_textdomain', 'wp_schedule_event', 'wp_clear_scheduled_hook',
];
foreach ($_wp_voids as $fn) {
if (!function_exists($fn)) {
eval("function $fn() { return true; }");
}
}
$_wp_returns_first = ['apply_filters', 'sanitize_text_field', 'sanitize_key', 'wp_unslash'];
foreach ($_wp_returns_first as $fn) {
if (!function_exists($fn)) {
eval("function $fn(\$a) { return \$a; }");
}
}
if (!function_exists('get_option')) { function get_option($k, $d = false) { return $d; } }
if (!function_exists('update_option')) { function update_option() { return true; } }
if (!function_exists('add_option')) { function add_option() { return true; } }
if (!function_exists('delete_option')) { function delete_option() { return true; } }
if (!function_exists('wp_next_scheduled')) { function wp_next_scheduled() { return false; } }
if (!function_exists('plugin_dir_path')) { function plugin_dir_path($f) { return dirname($f); } }
if (!function_exists('plugin_basename')) { function plugin_basename($f) { return basename($f); } }
if (!function_exists('untrailingslashit')) { function untrailingslashit($s) { return rtrim($s, '/\\'); } }
if (!function_exists('trailingslashit')) { function trailingslashit($s) { return rtrim($s, '/\\') . '/'; } }
if (!function_exists('wp_parse_url')) { function wp_parse_url($u, $c = -1) { return $c === -1 ? parse_url($u) : parse_url($u, $c); } }
eval($src);
// --- Tests ---------------------------------------------------------------
$pass = 0; $fail = 0;
function check($label, $ok, $detail = '') {
global $pass, $fail;
if ($ok) { echo " \033[32mPASS\033[0m $label\n"; $pass++; }
else { echo " \033[31mFAIL\033[0m $label" . ($detail ? "$detail" : '') . "\n"; $fail++; }
}
$bridge = new Airano_MCP_Bridge();
echo "\n\033[1mrequire_upload_and_attach_capability — gate semantics\033[0m\n";
// 1. No upload_files + no manage_options → forbidden
$GLOBALS['__caps'] = ['upload_files' => false, 'manage_options' => false];
$GLOBALS['__edits'] = [];
$res = $bridge->require_upload_and_attach_capability(new WP_REST_Request([]));
check('caller without upload_files/manage_options is rejected',
$res instanceof WP_Error && $res->code === 'rest_forbidden');
// 2. Has upload_files, no attach_to_post → allowed (no per-target check)
$GLOBALS['__caps'] = ['upload_files' => true];
$res = $bridge->require_upload_and_attach_capability(new WP_REST_Request([]));
check('upload_files alone, no attach_to_post → true', $res === true,
is_object($res) ? "got " . $res->code : "got " . var_export($res, true));
// 3. Has upload_files, attach_to_post=42, no edit_post → forbidden
$GLOBALS['__caps'] = ['upload_files' => true];
$GLOBALS['__edits'] = [42 => false];
$res = $bridge->require_upload_and_attach_capability(new WP_REST_Request(['attach_to_post' => 42]));
check('attach_to_post supplied, edit_post denied → rest_cannot_edit',
$res instanceof WP_Error && $res->code === 'rest_cannot_edit',
is_object($res) ? "got code: " . $res->code : "got: " . var_export($res, true));
// 4. Has upload_files, attach_to_post=42, edit_post granted → allowed
$GLOBALS['__caps'] = ['upload_files' => true];
$GLOBALS['__edits'] = [42 => true];
$res = $bridge->require_upload_and_attach_capability(new WP_REST_Request(['attach_to_post' => 42]));
check('attach_to_post + edit_post → true', $res === true,
is_object($res) ? "got " . $res->code : "got " . var_export($res, true));
// 5. set_featured=true without attach_to_post → invalid_param
$GLOBALS['__caps'] = ['upload_files' => true];
$res = $bridge->require_upload_and_attach_capability(new WP_REST_Request(['set_featured' => 'true']));
check('set_featured without attach_to_post → rest_invalid_param',
$res instanceof WP_Error && $res->code === 'rest_invalid_param');
// 6. set_featured=1 without attach_to_post → invalid_param (numeric string)
$res = $bridge->require_upload_and_attach_capability(new WP_REST_Request(['set_featured' => '1']));
check("set_featured='1' without attach_to_post → rest_invalid_param",
$res instanceof WP_Error && $res->code === 'rest_invalid_param');
// 7. set_featured=false → does NOT trigger the invalid_param branch
$res = $bridge->require_upload_and_attach_capability(new WP_REST_Request(['set_featured' => 'false']));
check("set_featured='false' (no attach_to_post) → allowed",
$res === true,
is_object($res) ? "got " . $res->code : "got " . var_export($res, true));
// 8. set_featured=true + attach_to_post=42 + edit_post=true → allowed
$GLOBALS['__edits'] = [42 => true];
$res = $bridge->require_upload_and_attach_capability(
new WP_REST_Request(['attach_to_post' => 42, 'set_featured' => 'true']));
check('set_featured=true + attach_to_post + edit_post → true', $res === true);
// 9. manage_options-only caller (no upload_files) is allowed (admins still pass)
$GLOBALS['__caps'] = ['upload_files' => false, 'manage_options' => true];
$GLOBALS['__edits'] = [];
$res = $bridge->require_upload_and_attach_capability(new WP_REST_Request([]));
check('manage_options without upload_files → true', $res === true);
// 10. attach_to_post=0 (defaulted/missing) is treated as "not supplied" — no
// per-target check needed.
$GLOBALS['__caps'] = ['upload_files' => true];
$res = $bridge->require_upload_and_attach_capability(new WP_REST_Request(['attach_to_post' => 0]));
check('attach_to_post=0 → no per-target check, allowed', $res === true);
echo "\n";
if ($fail === 0) {
echo "\033[32m✓ $pass tests passed\033[0m\n";
exit(0);
} else {
echo "\033[31m✗ $fail of " . ($pass + $fail) . " tests failed\033[0m\n";
exit(1);
}