
A WordPress plugin exposing an MCP server over the REST API, with the security model as the point -- closes the CVE-2026-15015 OAuth-bypass shape by never having that surface at all.
In plain terms: this lets an AI agent read and lightly write to a WordPress site through a real WordPress Application Password — the same credential type wp-admin already issues — and nothing else. There is no sign-up form, no OAuth client registration, no token endpoint of any kind here. A plugin in this same space shipped exactly that, and it is how an unauthenticated attacker walked out with full admin access.
A WordPress plugin exposing an MCP server over the REST API, with the security model as the point, not a feature bullet.
CVE-2026-15015, CVSS 9.8: the MountDev AI MCP Connector for WordPress exposed a publicly accessible OAuth 2.1 Dynamic Client Registration endpoint alongside an authorization endpoint that didn't check who was asking either. An attacker registers their own OAuth client — no credentials required, the endpoint is unauthenticated by design, that's what "dynamic client registration" means — walks it through the authorization endpoint unattended, and receives a bearer token bound to an administrator account. Full site control: content, users, settings. Nothing was misconfigured; the flow worked exactly as OAuth client registration is supposed to. It just should never have been reachable without an admin already being present to approve it.
The fix that generalises isn't "implement OAuth more carefully." It's not
having that surface. This plugin registers no client-registration
endpoint, no authorization endpoint, and no token-issuance endpoint of any
kind. There is nothing for an attacker to register against, because there
is nothing here that hands out credentials. Authentication is a WordPress
Application Password an administrator already created for a specific user
from wp-admin — a credential that exists only because a human with
manage_options chose to create it, ahead of time, out of band from this
plugin entirely.
Neither one alone is new; running both, on purpose, independent of each other, is what closes off a mistake in either one:
1. Application Password only — never a cookie session. WordPress's own
is_user_logged_in() is also true for a browser tab holding a valid
cookie and nonce. Auth::permission_callback() refuses that
path deliberately: it listens for the application_password_did_authenticate
action WordPress core fires specifically when Basic-auth Application
Password authentication succeeds, and requires that flag, not just "some
user is logged in." An MCP client is not a browser tab with a nonce;
accepting cookie auth here would open a CSRF-shaped path into a tool surface
that can be told, in English, to modify content, for no benefit over the
one door this plugin actually needs.
2. Per-tool capability, plus a write gate that capability alone can't
open. ToolRegistry::call() checks
user_can($user_id, $capability) fresh on every call — edit_posts for
posts, manage_woocommerce for orders. Separately, the one write tool,
create_draft_post, additionally requires an administrator to have opted in
from Settings → WP Secure MCP, off by default. An Application Password
that happens to carry edit_posts still cannot write anything until a human
has flipped that switch — a capability check and a site-wide gate are two
different locks, and the tests prove neither one substitutes for the
other in either direction.
limit, 1–20) bound
how much a single call can return; a legitimately expensive WP_Query or
wc_get_orders call still costs what it costs.Every tool's tools/list entry carries readOnlyHint / destructiveHint
annotations, so a client can decide whether to ask a human before calling
one without needing to already know what it does.
composer install --no-dev
Copy (or symlink) the plugin directory into wp-content/plugins/, activate
it from wp-admin, then create an Application Password for the account an
agent should act as: Users → your profile → Application Passwords.
Writes are off by default. To allow create_draft_post: Settings → WP
Secure MCP → Write tools. The per-tool capability check still applies on
top of this — flipping the site-wide switch does not grant anyone a
capability they didn't already have.
55 tests, all pure — no WordPress install, no database, no Docker.
tests/bootstrap.php stubs the WordPress functions
src/ calls (current_user_can, get_post, wp_insert_post, ...) the same
way a WordPress plugin is conventionally unit-tested without loading
WordPress itself.
composer install
vendor/bin/phpunit --testdox
The heaviest coverage sits where it matters most:
ProtocolTest — 17 tests against a fake
registry, nothing WordPress-specific at all. JSON-RPC envelope validation,
every error code, and the JSON-RPC 2.0 notification rule that a request
with no id field gets no response, for any method, even a malformed
one — there's nowhere for the error to go.ToolRegistryTest — proves the capability
check and the write gate are independent in both directions: a missing
capability blocks a call even when writes are enabled, and a disabled
write gate blocks a call even when the capability is present.AuthTest — the test that matters most here is
test_logged_in_user_without_application_password_authentication_is_refused:
a real, resolved WordPress user is still refused, because a cookie
session was never asked for.CI runs PHPUnit across PHP 8.1–8.3 and PHP_CodeSniffer against the WordPress Coding Standards ruleset on every push.
What CI does not yet run on every push: a live WordPress +
WooCommerce integration test via
@wordpress/env — authenticate with a real Application
Password against a real REST API and a real database, the live-instance
equivalent of pg-readonly-mcp's
real-Postgres CI job. It's written and reasoned through, wired as a
workflow_dispatch job in ci.yml rather than
a required gate, because this repository's own development environment hit
a local Docker Desktop failure that made it impossible to rehearse wp-env
before this workflow's first real run. Said here rather than left for
someone else to discover — the same rule pg-readonly-mcp applies to its own
untested parser-differential gap.
wp-secure-mcp.php plugin bootstrap: loads the autoloader, wires one REST route
src/
Protocol.php JSON-RPC 2.0 dispatch. Zero WordPress calls. Pure.
ToolRegistryInterface.php the seam Protocol talks to instead of WordPress directly
ToolRegistry.php the two locks: per-tool capability, server-wide write gate
Auth.php Application-Password-only permission_callback
Settings.php the write-gate admin toggle
RestController.php thin bootstrap: one route, delegates immediately
Tools/ the four v1 tools
tests/
bootstrap.php WordPress function stubs
ProtocolTest.php, ToolRegistryTest.php, AuthTest.php, ...
bin/smoke-test.sh live wp-env integration script (see Tests, above)
If a request is ever accepted or refused for a reason that lives in
wp-secure-mcp.php or RestController.php instead of Auth, ToolRegistry,
or Protocol, that's a bug — the judgement belongs one layer down, where it
can be tested with a plain array and nothing else.
MIT.
| Tool | Capability | Read-only | Notes |
|---|
search_posts | edit_posts | Yes | Keyword search. Hardcoded to post_status=publish — never returns drafts or private posts, even to a caller who could see them in wp-admin. |
get_post | edit_posts | Yes | Fetch by ID. Refuses a real post at a real ID if its status isn't publish — an ID is not a bypass around the same rule search_posts enforces. |
list_recent_orders | manage_woocommerce | Yes | Status, total, currency, date only. Never customer name, email, or address — order visibility doesn't require handing an agent a customer's PII, capability check or not. |
create_draft_post | edit_posts | No | post_status is a literal 'draft' in the source, not an argument. This tool cannot publish under any input, even with writes enabled. Off site-wide until an administrator opts in. |