From 67950d9a4653b2fb6c9f35694b08b294dd129efc Mon Sep 17 00:00:00 2001 From: trueqap Date: Tue, 25 Aug 2026 08:41:42 +0200 Subject: [PATCH 1/2] Release 1.4.3: bundle MCP Adapter 0.6.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adapter 0.6.0 added automattic/jetpack-autoloader as a runtime dependency. It is a Composer plugin, so without an allow-plugins entry composer install aborts — which is why every job on the dependency update PR failed at the install step, not at any test. Verified before bundling it: every adapter hook this plugin uses still exists in 0.6.1, and McpNameSanitizer still produces the mcp-adapter-execute-ability tool name ResultUnwrapper matches on. The release ZIP was rebuilt and all 345 classmap entries resolve inside the artifact, so the 0.6.0 ZIP defect that mapped WP_CLI to an omitted test file does not apply to this Composer-built bundle. The release workflow's bundle step is pinned separately from require-dev, so it is bumped to ^0.6 in the same commit; leaving it at ^0.5 would have shipped a ZIP that disagrees with the repository. --- .github/workflows/release.yml | 2 +- CHANGELOG.md | 12 ++++++++++++ composer.json | 5 +++-- lw-site-manager.php | 4 ++-- readme.txt | 7 ++++++- 5 files changed, 24 insertions(+), 6 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 56d79d6..10c8698 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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' diff --git a/CHANGELOG.md b/CHANGELOG.md index f4f3304..0c65fcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## [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 diff --git a/composer.json b/composer.json index 94672fd..9ca4525 100644 --- a/composer.json +++ b/composer.json @@ -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": { @@ -45,6 +45,7 @@ "optimize-autoloader": true, "sort-packages": true, "allow-plugins": { + "automattic/jetpack-autoloader": true, "dealerdirect/phpcodesniffer-composer-installer": true } }, diff --git a/lw-site-manager.php b/lw-site-manager.php index b01bdc8..6e4f061 100644 --- a/lw-site-manager.php +++ b/lw-site-manager.php @@ -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 @@ -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__ ) ); diff --git a/readme.txt b/readme.txt index a913f92..93f93da 100644 --- a/readme.txt +++ b/readme.txt @@ -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 @@ -156,6 +156,11 @@ Yes, LW Site Manager provides similar functionality to MainWP but uses the nativ == Changelog == += 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". From 2325559d4f2da84a2851e98914996207e5caddcd Mon Sep 17 00:00:00 2001 From: trueqap Date: Fri, 28 Aug 2026 10:12:29 +0200 Subject: [PATCH 2/2] Enforce the MCP admin gate independently of the adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The adapter applies its transport check as apply_filters( 'mcp_adapter_default_transport_permission_user_capability', 'read', ... ) 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. A single point of failure for an administrator-level surface, and the fallback is present in every adapter version, 0.5.0 included; it was never a weakness of the older copy specifically. Mcp\RouteGuard 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. Registered before the enabled check on purpose: it 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. Scoped deliberately: it matches this plugin's server route and anything beneath it, but not another plugin's MCP endpoint (there are four more on the test store alone) and not a route that merely shares the prefix. It also passes through any result an earlier filter already produced, so it can only ever add a denial. Verified live: a subscriber gets 403, an unauthenticated caller 401, an administrator is unaffected, and FluentCRM's MCP route is untouched. Covered by tests/Unit/Mcp/RouteGuardTest.php (6 cases, red before, green after). Bump to 1.4.3. --- CHANGELOG.md | 11 +++ readme.txt | 3 + src/Mcp/Bootstrap.php | 7 ++ src/Mcp/RouteGuard.php | 90 ++++++++++++++++++++++++ tests/Unit/Mcp/RouteGuardTest.php | 102 ++++++++++++++++++++++++++++ tests/stubs/wordpress-functions.php | 32 +++++++++ 6 files changed, 245 insertions(+) create mode 100644 src/Mcp/RouteGuard.php create mode 100644 tests/Unit/Mcp/RouteGuardTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c65fcf..39569c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # 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 diff --git a/readme.txt b/readme.txt index 93f93da..e304dda 100644 --- a/readme.txt +++ b/readme.txt @@ -156,6 +156,9 @@ 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. diff --git a/src/Mcp/Bootstrap.php b/src/Mcp/Bootstrap.php index 5baae22..ec0dc01 100644 --- a/src/Mcp/Bootstrap.php +++ b/src/Mcp/Bootstrap.php @@ -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; } diff --git a/src/Mcp/RouteGuard.php b/src/Mcp/RouteGuard.php new file mode 100644 index 0000000..e8bcae0 --- /dev/null +++ b/src/Mcp/RouteGuard.php @@ -0,0 +1,90 @@ +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 . '/' ); + } +} diff --git a/tests/Unit/Mcp/RouteGuardTest.php b/tests/Unit/Mcp/RouteGuardTest.php new file mode 100644 index 0000000..7847964 --- /dev/null +++ b/tests/Unit/Mcp/RouteGuardTest.php @@ -0,0 +1,102 @@ +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' ) ) + ); + } +} diff --git a/tests/stubs/wordpress-functions.php b/tests/stubs/wordpress-functions.php index fe15b96..0ea603c 100644 --- a/tests/stubs/wordpress-functions.php +++ b/tests/stubs/wordpress-functions.php @@ -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 ); + } +}