Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ jobs:
# so the built-in MCP server keeps working out of the box.
- name: Bundle the MCP adapter into the release ZIP
if: steps.check_release.outputs.exists == 'false'
run: composer require wordpress/mcp-adapter:^0.5 --update-no-dev --optimize-autoloader --no-interaction
run: composer require wordpress/mcp-adapter:^0.6 --update-no-dev --optimize-autoloader --no-interaction

- name: Create release ZIP
if: steps.check_release.outputs.exists == 'false'
Expand Down
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,28 @@
# Changelog

## [1.4.3] - 2026-08-28

### Security
- **The MCP endpoint no longer depends on a single adapter hook for its admin gate.** The adapter applies its transport check as `apply_filters( 'mcp_adapter_default_transport_permission_user_capability', 'read', ... )` — note the default. `Mcp\TransportGuard` raises that to `manage_options`, but only for as long as that one filter is actually applied. If it ever is not — an upstream refactor, a hook rename, or a different bundled copy of the library winning the autoload race, which already happens on every WooCommerce store — the endpoint falls back to `read`, a capability every logged-in subscriber holds.

`Mcp\RouteGuard` now enforces the same capability on WordPress's own `rest_pre_dispatch`, so the gate holds regardless of which adapter is loaded or whether its filters fire. It is registered before the enabled check, since the point is that it holds when something else has gone wrong. It matches only this plugin's server route and anything beneath it — not another plugin's MCP endpoint, and not a route that merely shares the prefix — and never overrides an answer another filter already produced.

Note this fallback is present in **every** adapter version, 0.5.0 included; it was not a weakness of the older bundled copy.

Verified live: a subscriber receives `403`, an unauthenticated caller `401`, an administrator is unaffected, and another plugin's MCP route is untouched.

## [1.4.3] - 2026-08-25

### Changed
- The MCP Adapter bundled in the release ZIP moves from v0.5.0 to v0.6.1 (`wordpress/mcp-adapter` in `require-dev` and in the release workflow's bundle step). Verified against every adapter hook this plugin uses — `mcp_adapter_default_server_config`, `mcp_adapter_default_transport_permission_user_capability`, `mcp_adapter_tool_call_result`, `mcp_adapter_init` and the `mcp-adapter/discover-abilities` ability all still exist, and `McpNameSanitizer` still turns `mcp-adapter/execute-ability` into the `mcp-adapter-execute-ability` tool name `Mcp\ResultUnwrapper` matches on.
- `composer.json` allows the `automattic/jetpack-autoloader` Composer plugin. Adapter 0.6.0 made it a runtime dependency; without the entry `composer install` aborts, which is what broke CI on the dependency-update PR. The adapter uses it so the newest `WP\MCP` classes win when several plugins bundle their own copy — the same class of problem the outdated-adapter notice added in 1.4.2 reports.

### Notes
- **Upstream behaviour changes inherited from adapter 0.6.0.** Abilities registered by *other* plugins with `meta.public: true` are now exposed through the adapter's default MCP server unless they set `meta.mcp.public` to false. This plugin's own abilities are unaffected: `Mcp\AbilityExposer` has always set `meta.mcp.public` explicitly on every `site-manager/*` ability, and the transport still enforces the capability from `Mcp\TransportGuard`.
- On multisite only, active Streamable HTTP sessions must reconnect once after the update: adapter 0.6.0 moved session storage from a network-wide key to per-site keys. Single-site installs are unaffected.
- The adapter's own minimum is WordPress 6.9, which this plugin already required — no change to the supported floor.
- The release ZIP was rebuilt and checked: it ships only `automattic/`, `composer/` and `wordpress/` under `vendor/`, and all 345 classmap entries resolve inside the artifact. The 0.6.0 release-ZIP defect that mapped `WP_CLI` to an omitted test file is fixed in 0.6.1 and never applied to this build, which installs the adapter through Composer.

## [1.4.2] - 2026-08-24

### Fixed
Expand Down
5 changes: 3 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,12 @@
"phpunit/phpunit": "^9.6",
"squizlabs/php_codesniffer": "^3.13",
"szepeviktor/phpstan-wordpress": "^2.0",
"wordpress/mcp-adapter": "^0.5",
"wordpress/mcp-adapter": "^0.6",
"wp-coding-standards/wpcs": "^3.3",
"yoast/phpunit-polyfills": "^4.0"
},
"suggest": {
"wordpress/mcp-adapter": "^0.5 — powers the built-in MCP server (LW Plugins → AI / MCP). Not needed for the REST / Abilities API. Bundled in the wordpress.org ZIP. Composer installs that want the MCP server add it themselves — but note that on a WooCommerce store WooCommerce bundles its own older copy and loads it first, so the version you install may not be the one that runs; the plugin shows an admin notice when that happens."
"wordpress/mcp-adapter": "^0.6 — powers the built-in MCP server (LW Plugins → AI / MCP). Not needed for the REST / Abilities API. Bundled in the wordpress.org ZIP. Composer installs that want the MCP server add it themselves — but note that on a WooCommerce store WooCommerce bundles its own older copy and loads it first, so the version you install may not be the one that runs; the plugin shows an admin notice when that happens."
},
"autoload": {
"psr-4": {
Expand All @@ -45,6 +45,7 @@
"optimize-autoloader": true,
"sort-packages": true,
"allow-plugins": {
"automattic/jetpack-autoloader": true,
"dealerdirect/phpcodesniffer-composer-installer": true
}
},
Expand Down
4 changes: 2 additions & 2 deletions lw-site-manager.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* Plugin Name: LW Site Manager
* Plugin URI: https://github.com/lwplugins/lw-site-manager
* Description: Lightweight site manager — full site maintenance via AI/REST using Abilities API.
* Version: 1.4.2
* Version: 1.4.3
* Requires at least: 6.9
* Requires PHP: 8.2
* Author: LW Plugins
Expand All @@ -24,7 +24,7 @@
}

// Plugin constants.
define( 'LW_SITE_MANAGER_VERSION', '1.4.2' );
define( 'LW_SITE_MANAGER_VERSION', '1.4.3' );
define( 'LW_SITE_MANAGER_FILE', __FILE__ );
define( 'LW_SITE_MANAGER_DIR', plugin_dir_path( __FILE__ ) );
define( 'LW_SITE_MANAGER_URL', plugin_dir_url( __FILE__ ) );
Expand Down
10 changes: 9 additions & 1 deletion readme.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Tags: site-manager, maintenance, ai, rest-api, abilities
Requires at least: 6.9
Tested up to: 7.1
Requires PHP: 8.2
Stable tag: 1.4.2
Stable tag: 1.4.3
License: GPLv2 or later
License URI: https://www.gnu.org/licenses/gpl-2.0.html

Expand Down Expand Up @@ -156,6 +156,14 @@ Yes, LW Site Manager provides similar functionality to MainWP but uses the nativ

== Changelog ==

= 1.4.3 =
* Security: The MCP endpoint now enforces its administrator requirement itself, instead of relying solely on the adapter library applying our filter. The library defaults to the "read" capability, which every logged-in subscriber has, so a single hook not firing would have quietly opened the endpoint to any logged-in user. Verified: a subscriber now gets 403, an administrator is unaffected, and other plugins' MCP endpoints are untouched.

= 1.4.3 =
* Update: The MCP Adapter library bundled in the plugin ZIP moves from 0.5.0 to 0.6.1, which brings better MCP resource metadata handling, more reliable sessions, and — because the adapter now uses the Jetpack Autoloader — a better chance that the newest copy wins when several plugins bundle their own.
* Note: Abilities registered by other plugins as public are now exposed through the adapter's default MCP server unless they opt out. This plugin's own abilities are unchanged, and the MCP transport still requires the same capability as before.
* Note: On multisite only, open MCP connections need to reconnect once after updating, because session storage moved to per-site keys. Single sites are unaffected.

= 1.4.2 =
* Fix: Failed plugin/theme installs and activations were reported as successful. They returned a 200 with success:false in the body, which the MCP layer then wrapped into a success envelope — so an AI agent was told the work was done when it had not been. These now return a proper error, over MCP and over REST alike, with the captured PHP errors kept in the error detail.
* Fix: The WooCommerce report totals (orders, customers, products, coupons) returned an empty report instead of an error when WooCommerce was inactive, making "no store" indistinguishable from "no data".
Expand Down
7 changes: 7 additions & 0 deletions src/Mcp/Bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ final class Bootstrap {
* Boot the MCP subsystem if enabled and the adapter is available.
*/
public static function init(): void {
// Registered before the enabled check on purpose. This guard only ever
// denies requests to our own route, so it costs nothing when the server
// is off — and it must not depend on our own bootstrapping having got
// that far, since the whole point is that it holds when something else
// has gone wrong.
RouteGuard::register();

if ( ! Toggle::is_enabled() ) {
return;
}
Expand Down
90 changes: 90 additions & 0 deletions src/Mcp/RouteGuard.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?php
/**
* Adapter-independent capability gate on the plugin's MCP route.
*
* @package LightweightPlugins\SiteManager\Mcp
*/

declare(strict_types=1);

namespace LightweightPlugins\SiteManager\Mcp;

if ( ! defined( 'ABSPATH' ) ) {
exit;
}

/**
* Second, independent capability check on this plugin's MCP endpoint.
*
* The adapter applies its own gate as
* `apply_filters( 'mcp_adapter_default_transport_permission_user_capability', 'read', ... )`
* — note the default value. TransportGuard raises that to `manage_options`, but
* only for as long as that one filter is actually applied. If it stops being
* applied for any reason — an upstream refactor, a hook rename, a different
* bundled copy of the library winning the autoload race, which is exactly what
* already happens on WooCommerce stores — the endpoint quietly falls back to
* `read`, a capability every logged-in subscriber holds.
*
* That is a single point of failure for an administrator-level surface, and the
* fallback is present in every adapter version, not just the older one. This
* layer runs on WordPress's own REST dispatch instead, so it holds regardless of
* which adapter is loaded or whether its filters fire.
*/
final class RouteGuard {

/**
* Hook the guard onto REST dispatch.
*/
public static function register(): void {
add_filter( 'rest_pre_dispatch', [ self::class, 'guard' ], 10, 3 );
}

/**
* Deny requests to this plugin's MCP route from callers without the capability.
*
* @param mixed $result Response to replace the request with, or null to continue.
* @param mixed $server REST server instance (unused).
* @param mixed $request The request being dispatched.
* @return mixed
*/
public static function guard( mixed $result, mixed $server, mixed $request ): mixed {
// Never override an answer another filter already produced.
if ( null !== $result ) {
return $result;
}

if ( ! is_object( $request ) || ! method_exists( $request, 'get_route' ) ) {
return $result;
}

if ( ! self::isOwnRoute( (string) $request->get_route() ) ) {
return $result;
}

if ( current_user_can( TransportGuard::capability( null ) ) ) {
return $result;
}

return new \WP_Error(
'rest_forbidden',
__( 'You are not allowed to access the MCP endpoint.', 'lw-site-manager' ),
[ 'status' => is_user_logged_in() ? 403 : 401 ]
);
}

/**
* Whether a REST route belongs to this plugin's MCP server.
*
* Matches the server route and anything beneath it, but not a route that
* merely shares the same prefix (`…-other`), and not another plugin's MCP
* server, which we have no business gating.
*
* @param string $route Route path, e.g. /mcp/lw-site-manager.
*/
public static function isOwnRoute( string $route ): bool {
$ours = '/' . Server::ROUTE_NS . '/' . Server::SERVER_ROUTE;
$route = '/' . ltrim( $route, '/' );

return $route === $ours || str_starts_with( $route, $ours . '/' );
}
}
102 changes: 102 additions & 0 deletions tests/Unit/Mcp/RouteGuardTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<?php
/**
* Independent capability gate on the MCP route.
*
* The adapter's own gate is applied as
* `apply_filters( 'mcp_adapter_default_transport_permission_user_capability', 'read', ... )`
* — note the default. If our filter is ever not applied (a refactor upstream, a
* different bundled copy winning the autoload race, a hook rename), the endpoint
* silently falls back to `read`, which every logged-in subscriber has. The whole
* admin gate rests on one hook firing, in every adapter version.
*
* This layer does not depend on the adapter at all.
*
* @package LightweightPlugins\SiteManager\Tests\Unit\Mcp
*/

declare(strict_types=1);

namespace LightweightPlugins\SiteManager\Tests\Unit\Mcp;

use LightweightPlugins\SiteManager\Mcp\RouteGuard;
use PHPUnit\Framework\TestCase;

final class RouteGuardTest extends TestCase {

protected function setUp(): void {
parent::setUp();
reset_wp_caps();
reset_wp_filters();
}

protected function tearDown(): void {
reset_wp_caps();
parent::tearDown();
}

private function request( string $route ): \WP_REST_Request {
return new \WP_REST_Request( 'POST', $route );
}

public function test_blocks_our_mcp_route_without_the_capability(): void {
grant_wp_caps( [ 'read' ] ); // A subscriber: what the adapter default would allow.

$result = RouteGuard::guard( null, null, $this->request( '/mcp/lw-site-manager' ) );

$this->assertInstanceOf( \WP_Error::class, $result );
$this->assertSame( 'rest_forbidden', $result->get_error_code() );
}

public function test_allows_our_mcp_route_for_an_administrator(): void {
grant_wp_caps( [ 'read', 'manage_options' ] );

$result = RouteGuard::guard( null, null, $this->request( '/mcp/lw-site-manager' ) );

$this->assertNull( $result, 'an allowed request must pass the filter value through untouched' );
}

/**
* Sub-paths of our server must be covered too, not just the exact route.
*/
public function test_blocks_sub_paths_of_our_mcp_route(): void {
grant_wp_caps( [ 'read' ] );

$result = RouteGuard::guard( null, null, $this->request( '/mcp/lw-site-manager/anything' ) );

$this->assertInstanceOf( \WP_Error::class, $result );
}

/**
* Another plugin's MCP server is none of our business — guarding it would
* break their endpoint.
*/
public function test_ignores_another_plugins_mcp_route(): void {
grant_wp_caps( [ 'read' ] );

$this->assertNull( RouteGuard::guard( null, null, $this->request( '/mcp/fluent-crm' ) ) );
$this->assertNull( RouteGuard::guard( null, null, $this->request( '/wp/v2/posts' ) ) );
}

/**
* A route that merely starts with the same characters must not be caught.
*/
public function test_ignores_a_similarly_named_route(): void {
grant_wp_caps( [ 'read' ] );

$this->assertNull( RouteGuard::guard( null, null, $this->request( '/mcp/lw-site-manager-other' ) ) );
}

/**
* An earlier filter that already produced a response or error must win —
* this guard only ever adds a denial, never overrides one.
*/
public function test_passes_an_existing_result_through(): void {
grant_wp_caps( [ 'manage_options' ] );
$existing = new \WP_Error( 'something_else', 'already handled' );

$this->assertSame(
$existing,
RouteGuard::guard( $existing, null, $this->request( '/mcp/lw-site-manager' ) )
);
}
}
32 changes: 32 additions & 0 deletions tests/stubs/wordpress-functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -2242,3 +2242,35 @@ function reset_wp_comments(): void {
$GLOBALS['wp_comment_counts_stub']
);
}

if ( ! class_exists( 'WP_REST_Request' ) ) {
/**
* Minimal WP_REST_Request stub.
*/
class WP_REST_Request {
private string $method;
private string $route;

public function __construct( string $method = 'GET', string $route = '' ) {
$this->method = $method;
$this->route = $route;
}

public function get_method(): string {
return $this->method;
}

public function get_route(): string {
return $this->route;
}
}
}

if ( ! function_exists( 'is_user_logged_in' ) ) {
/**
* Whether a user is logged in.
*/
function is_user_logged_in(): bool {
return (bool) ( $GLOBALS['wp_user_logged_in'] ?? false );
}
}