Commit 6c38318038f for woocommerce
commit 6c38318038f6ca159020c34e17b4afa72f3328a1
Author: Darren Ethier <darren@roughsmootheng.in>
Date: Fri Oct 16 11:54:46 2026 -0400
Remove the deprecated WooCommerce MCP endpoint and stop bundling the MCP Adapter (#69787)
Remove deprecated MCP endpoint and bundled adapter (#69787)
Stop bundling wordpress/mcp-adapter and wordpress/abilities-api.
Retire mcp_integration and nine REST-derived abilities; keep polyfills.
Return 410 migration guidance and show enabled stores an adapter notice. This is a managed, intentional BC break which also removes three filters (see [this post](https://developer.woocommerce.com/2026/05/12/mcp-abilities-api-10-9/) for details.
Keeps seven current canonical woocommerce/* abilities (which are exposed on the default MCP Adapter's server when the WordPress MCP Adapter plugin is installed)
Closes #69345.
diff --git a/docs/features/mcp/README.md b/docs/features/mcp/README.md
index e2ff27b8637..39cbbf9bdcf 100644
--- a/docs/features/mcp/README.md
+++ b/docs/features/mcp/README.md
@@ -19,10 +19,10 @@ The MCP implementation in WooCommerce is currently in developer preview. Impleme
## Background
-The Model Context Protocol (MCP) is an open standard that enables AI applications to securely connect to external data sources and tools. WooCommerce's MCP integration builds on two core technologies:
+The Model Context Protocol (MCP) is an open standard that enables AI applications to securely connect to external data sources and tools. Using MCP with WooCommerce relies on two core technologies:
-- **[WordPress Abilities API](https://github.com/WordPress/abilities-api)** - A standardized system for registering capabilities in WordPress
-- **[WordPress MCP Adapter](https://github.com/WordPress/mcp-adapter)** - The core MCP protocol implementation
+- **[WordPress Abilities API](https://github.com/WordPress/abilities-api)** - A standardized system for registering capabilities in WordPress, included in WordPress core
+- **[WordPress MCP Adapter](https://github.com/WordPress/mcp-adapter)** - The core MCP protocol implementation, installed as the [MCP Adapter plugin](https://wordpress.org/plugins/mcp-adapter/)
This architecture allows WooCommerce to expose operations as MCP tools through the flexible WordPress Abilities system while maintaining existing security and permission models.
@@ -45,16 +45,23 @@ WooCommerce registers purpose-built abilities for core store operations. These a
- Update order status
- Add order notes
-### Deprecated WooCommerce MCP Endpoint
+All operations respect WooCommerce's existing permission system. MCP clients authenticate through the MCP Adapter as a WordPress user.
-The deprecated WooCommerce MCP endpoint also exposes REST-derived compatibility abilities for products and orders. These abilities map to existing REST API operations and currently include product list, retrieve, create, update, and delete operations, plus order list, retrieve, create, and update operations.
+### Removed in WooCommerce 11.3
-All operations respect WooCommerce's existing permission system. The deprecated WooCommerce MCP endpoint authenticates using WooCommerce REST API keys; clients using the shared WordPress MCP adapter should follow the adapter's authentication requirements.
+WooCommerce 11.3 removed the deprecated WooCommerce MCP endpoint and everything that only existed to support it:
+
+- The `/wp-json/woocommerce/mcp` endpoint and its WooCommerce REST API key authentication (`X-MCP-API-Key`). Requests to the endpoint now return HTTP 410 with a JSON-RPC error that explains how to connect through the MCP Adapter.
+- The REST-derived product and order abilities (`woocommerce/products-list`, `woocommerce/orders-get`, and similar), along with the `woocommerce_mcp_include_ability`, `woocommerce_mcp_allow_insecure_transport`, and `woocommerce_check_rest_ability_permissions_for_method` filters and the `expose_in_deprecated_woocommerce_mcp` ability metadata.
+
+Custom abilities registered by extensions or custom code that appeared in the removed endpoint don't carry over automatically. Before WooCommerce 10.9, the endpoint included abilities in the `woocommerce/` namespace; from 10.9, it included abilities with `expose_in_deprecated_woocommerce_mcp` set to `true`; and the `woocommerce_mcp_include_ability` filter could add or remove abilities in either version. The MCP Adapter's default server ignores all of these. It only exposes an ability when its `meta.mcp.public` is `true` (if `meta.mcp.public` is not set, the adapter falls back to the top-level `meta.public`). To keep a custom ability available to MCP clients, set that metadata as shown in [Adding Custom Abilities](#adding-custom-abilities).
+
+To keep using WooCommerce with MCP, install the [MCP Adapter plugin](https://wordpress.org/plugins/mcp-adapter/) and connect your MCP client to its default server, as described in [Connecting to the MCP Server](#connecting-to-the-mcp-server). Stores that had the WooCommerce MCP feature enabled see an admin notice about the change.
:::warning
**Data Privacy Notice**
-Order and customer operations may expose personally identifiable information (PII) including names, email addresses, physical addresses, and payment details. You are responsible for ensuring compliance with applicable data protection regulations. Use least-privilege API scopes, rotate and revoke REST API keys regularly, and follow your organization's data retention and handling policies.
+Order and customer operations may expose personally identifiable information (PII) including names, email addresses, physical addresses, and payment details. You are responsible for ensuring compliance with applicable data protection regulations. Connect MCP clients as a dedicated WordPress user with only the capabilities they need, revoke Application Passwords you no longer use, and follow your organization's data retention and handling policies.
:::
@@ -87,25 +94,17 @@ WooCommerce Core
**Remote WordPress MCP Server** (`mcp-adapter`)
-- Is provided by the bundled WordPress MCP Adapter package
+- Is provided by the [MCP Adapter plugin](https://wordpress.org/plugins/mcp-adapter/), which must be installed and active
- Powers MCP tool discovery and execution for WordPress abilities
-- Creates the deprecated `/wp-json/woocommerce/mcp` endpoint for WooCommerce compatibility
#### WordPress Abilities System
- Provides a standardized way to register and execute capabilities
- Acts as an abstraction layer between MCP tools and actual operations
-- Enables flexible implementation approaches (REST bridging, direct DB operations, etc.)
+- Enables flexible implementation approaches (direct operations, REST bridging, and so on)
### Core Components
-**MCP Adapter Provider** ([`MCPAdapterProvider.php`](https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/src/Internal/MCP/MCPAdapterProvider.php))
-
-- Initializes the bundled MCP adapter when the `mcp_integration` feature flag is enabled
-- Creates the deprecated WooCommerce MCP endpoint
-- Handles feature flag checking (`mcp_integration`)
-- Provides deprecated endpoint exposure filtering
-
**Abilities Registry** ([`AbilitiesRegistry.php`](https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/src/Internal/Abilities/AbilitiesRegistry.php))
- Initializes WooCommerce ability categories and loaders
@@ -118,41 +117,20 @@ WooCommerce Core
- Use shared WordPress MCP adapter metadata (`mcp.public` and `mcp.type`) for MCP exposure
- Keep the ability contract focused on agent-friendly store operations
-**REST Bridge Implementation** ([`AbilitiesRestBridge.php`](https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/src/Internal/Abilities/AbilitiesRestBridge.php))
-
-- Registers REST-derived compatibility abilities only while handling requests to the deprecated WooCommerce MCP endpoint
-- Provides explicit ability definitions with schemas for REST product and order operations
-- Marks those abilities with `expose_in_deprecated_woocommerce_mcp` metadata
-
-**WooCommerce Transport** ([`WooCommerceRestTransport.php`](https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/src/Internal/MCP/Transport/WooCommerceRestTransport.php))
-
-- Handles WooCommerce API key authentication
-- Enforces HTTPS requirements
-- Validates permissions based on API key scope
-
### Implementation Approach
-WooCommerce's preferred implementation path is purpose-built domain abilities. These abilities use schemas and response shapes designed for agent workflows instead of automatically projecting every REST-shaped operation into MCP.
-
-REST-derived abilities remain available as a compatibility layer for the deprecated WooCommerce MCP endpoint. This keeps existing clients working while allowing new abilities to use the shared WordPress MCP adapter without expanding the deprecated endpoint by namespace alone.
+WooCommerce implements its abilities as purpose-built domain abilities. These abilities use schemas and response shapes designed for agent workflows instead of automatically projecting every REST-shaped operation into MCP.
## Enabling MCP Integration
-The MCP feature is controlled by the `mcp_integration` feature flag. You can enable it programmatically:
-
-```php
-add_filter( 'woocommerce_features', function( $features ) {
- $features['mcp_integration'] = true;
- return $features;
-});
-```
-
-Alternatively, you can enable it via WooCommerce CLI:
+WooCommerce always registers its abilities through the WordPress Abilities API; there is no WooCommerce setting to turn on. To make them available to MCP clients, install and activate the [MCP Adapter plugin](https://wordpress.org/plugins/mcp-adapter/):
```bash
-wp option update woocommerce_feature_mcp_integration_enabled yes
+wp plugin install mcp-adapter --activate
```
+WooCommerce does not bundle the MCP adapter.
+
## Authentication and Security
### WordPress Application Password Requirements
@@ -184,7 +162,7 @@ WooCommerce abilities are available through the WordPress MCP Adapter's default
https://yourstore.com/wp-json/mcp/mcp-adapter-default-server
```
-The WooCommerce-specific `/wp-json/woocommerce/mcp` endpoint is deprecated and should not be used for new integrations.
+The WooCommerce-specific `/wp-json/woocommerce/mcp` endpoint was removed in WooCommerce 11.3. See [Removed in WooCommerce 11.3](#removed-in-woocommerce-113).
## Connecting to the MCP Server
@@ -364,32 +342,6 @@ add_action( 'wp_abilities_api_init', function() {
The `mcp.public` and `mcp.type` metadata tells the shared WordPress MCP adapter that the ability can be exposed as an MCP tool. The `show_in_rest` metadata exposes the ability through the Abilities API REST routes.
-### Including Custom Abilities in the Deprecated WooCommerce MCP Server
-
-REST-derived WooCommerce abilities include `expose_in_deprecated_woocommerce_mcp` metadata automatically. Custom abilities are not included by namespace alone; set this metadata to boolean `true` when registering the ability to include it in the deprecated WooCommerce MCP server by default:
-
-```php
-'meta' => array(
- 'show_in_rest' => true,
- 'mcp' => array(
- 'public' => true,
- 'type' => 'tool',
- ),
- 'expose_in_deprecated_woocommerce_mcp' => true,
-),
-```
-
-To override the default metadata decision at runtime, use the `woocommerce_mcp_include_ability` filter:
-
-```php
-add_filter( 'woocommerce_mcp_include_ability', function( $include, $ability_id ) {
- if ( str_starts_with( $ability_id, 'your-plugin/' ) ) {
- return true;
- }
- return $include;
-}, 10, 2 );
-```
-
## Development Example
For a complete working example, see the [WooCommerce MCP Ability Demo Plugin](https://github.com/woocommerce/wc-mcp-ability). This demonstration plugin shows how third-party developers can:
@@ -397,7 +349,7 @@ For a complete working example, see the [WooCommerce MCP Ability Demo Plugin](ht
- Register custom abilities using the WordPress Abilities API
- Define comprehensive input and output schemas
- Implement proper permission handling
-- Integrate with MCP through the shared WordPress MCP adapter or the deprecated WooCommerce MCP endpoint
+- Integrate with MCP through the shared WordPress MCP adapter
The demo plugin creates a `woocommerce-demo/store-info` ability that retrieves store information and statistics, demonstrating the integration patterns for extending WooCommerce MCP capabilities while using a direct implementation approach rather than REST endpoint bridging.
@@ -407,9 +359,8 @@ The demo plugin creates a `woocommerce-demo/store-info` ability that retrieves s
## MCP Server Not Available
-- Verify the `mcp_integration` feature flag is enabled
-- Check that the MCP adapter is properly loaded
-- Review WooCommerce logs for initialization errors
+- Verify the MCP Adapter plugin is installed and active
+- Confirm the client connects to `/wp-json/mcp/mcp-adapter-default-server`, not the removed `/wp-json/woocommerce/mcp` endpoint
## Authentication Failures
@@ -422,15 +373,14 @@ The demo plugin creates a `woocommerce-demo/store-info` ability that retrieves s
- Confirm the ability category is registered during `wp_abilities_api_categories_init`
- Confirm abilities are registered during `wp_abilities_api_init`
-- For the deprecated WooCommerce MCP endpoint, check the ability's `expose_in_deprecated_woocommerce_mcp` metadata or override inclusion using the `woocommerce_mcp_include_ability` filter
- Verify ability callbacks are accessible
-Check **WooCommerce → Status → Logs** for entries with source `woocommerce-mcp`.
+Check **WooCommerce → Status → Logs** for entries with source `woocommerce-abilities`.
## Important Considerations
- **Developer Preview**: This feature is in preview status and may change
-- **Implementation Approach**: WooCommerce uses purpose-built domain abilities and retains REST-derived compatibility abilities for the deprecated WooCommerce MCP endpoint
+- **Implementation Approach**: WooCommerce uses purpose-built domain abilities
- **Breaking Changes**: Future updates may introduce breaking changes
- **Production Testing**: Thoroughly test before deploying to production
- **API Stability**: The WordPress Abilities API and MCP adapter are evolving
diff --git a/plugins/woocommerce/changelog/wooplug-7878-unbundle-mcp-adapter b/plugins/woocommerce/changelog/wooplug-7878-unbundle-mcp-adapter
new file mode 100644
index 00000000000..2e1d0f1a042
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooplug-7878-unbundle-mcp-adapter
@@ -0,0 +1,4 @@
+Significance: minor
+Type: update
+
+Remove the deprecated WooCommerce MCP endpoint. Use the MCP Adapter plugin to access WooCommerce abilities over MCP.
diff --git a/plugins/woocommerce/composer.json b/plugins/woocommerce/composer.json
index 8c9caa1bf2a..7f58d178cfa 100644
--- a/plugins/woocommerce/composer.json
+++ b/plugins/woocommerce/composer.json
@@ -33,10 +33,6 @@
"symlink": false
}
},
- {
- "type": "vcs",
- "url": "https://github.com/WordPress/mcp-adapter.git"
- },
{
"type": "vcs",
"url": "https://github.com/WordPress/abilities-api"
@@ -56,9 +52,7 @@
"opis/json-schema": "*",
"woocommerce/action-scheduler": "4.1.0",
"woocommerce/blueprint": "*",
- "woocommerce/email-editor": "*",
- "wordpress/abilities-api": "^0.4.0",
- "wordpress/mcp-adapter": "^0.4.1"
+ "woocommerce/email-editor": "*"
},
"require-dev": {
"automattic/jetpack-changelogger": "^3.3.0",
@@ -73,6 +67,7 @@
"spatie/phpunit-watcher": "^1.23",
"szepeviktor/phpstan-wordpress": "^2.0",
"woocommerce/monorepo-plugin": "*",
+ "wordpress/abilities-api": "^0.4.0",
"yoast/phpunit-polyfills": "^2.0"
},
"config": {
diff --git a/plugins/woocommerce/composer.lock b/plugins/woocommerce/composer.lock
index 26dc9aa8ac1..0323d40344a 100644
--- a/plugins/woocommerce/composer.lock
+++ b/plugins/woocommerce/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "06d02ce09e769dc0580bad769b68083c",
+ "content-hash": "3d2aab352543dade8fac919263db0017",
"packages": [
{
"name": "automattic/block-delimiter",
@@ -1307,189 +1307,6 @@
"symlink": false,
"relative": true
}
- },
- {
- "name": "wordpress/abilities-api",
- "version": "v0.4.0",
- "source": {
- "type": "git",
- "url": "https://github.com/WordPress/abilities-api.git",
- "reference": "0759075aed37c4247adbf273bdebec096d52e825"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/WordPress/abilities-api/zipball/0759075aed37c4247adbf273bdebec096d52e825",
- "reference": "0759075aed37c4247adbf273bdebec096d52e825",
- "shasum": ""
- },
- "require": {
- "php": "^7.4 | ^8"
- },
- "require-dev": {
- "automattic/vipwpcs": "^3.0",
- "dealerdirect/phpcodesniffer-composer-installer": "^1.0",
- "phpcompatibility/php-compatibility": "10.x-dev as 9.99.99",
- "phpcompatibility/phpcompatibility-wp": "^2.1",
- "phpstan/extension-installer": "^1.3",
- "phpstan/phpstan": "^2.1.22",
- "phpstan/phpstan-deprecation-rules": "^2.0.3",
- "phpstan/phpstan-phpunit": "^2.0.3",
- "phpunit/phpunit": "^8.5|^9.6",
- "slevomat/coding-standard": "^8.0",
- "squizlabs/php_codesniffer": "^3.9",
- "szepeviktor/phpstan-wordpress": "^2.0.2",
- "wp-coding-standards/wpcs": "^3.1",
- "wp-phpunit/wp-phpunit": "^6.5",
- "wpackagist-plugin/plugin-check": "^1.6",
- "yoast/phpunit-polyfills": "^4.0"
- },
- "type": "library",
- "extra": {
- "installer-paths": {
- "vendor/{$vendor}/{$name}/": [
- "wpackagist-plugin/plugin-check"
- ]
- }
- },
- "autoload": {
- "files": [
- "includes/bootstrap.php"
- ]
- },
- "scripts": {
- "format": [
- "phpcbf"
- ],
- "lint": [
- "phpcs"
- ],
- "phpstan": [
- "phpstan analyse --memory-limit=1G"
- ],
- "test": [
- "phpunit --strict-coverage"
- ],
- "test-multisite": [
- "WP_MULTISITE=1 phpunit --exclude-group=ms-excluded"
- ]
- },
- "license": [
- "GPL-2.0-or-later"
- ],
- "authors": [
- {
- "name": "WordPress AI Team",
- "homepage": "https://make.wordpress.org/ai/"
- }
- ],
- "description": "AI Abilities for WordPress.",
- "homepage": "https://github.com/WordPress/abilities-api",
- "keywords": [
- "abilities",
- "ai",
- "api",
- "llm",
- "wordpress"
- ],
- "support": {
- "issues": "https://github.com/WordPress/abilities-api/issues",
- "source": "https://github.com/WordPress/abilities-api"
- },
- "time": "2025-10-29T05:35:31+00:00"
- },
- {
- "name": "wordpress/mcp-adapter",
- "version": "v0.4.1",
- "source": {
- "type": "git",
- "url": "https://github.com/WordPress/mcp-adapter.git",
- "reference": "1a0f9ab868e34b4375be42e873f60765e6632505"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/WordPress/mcp-adapter/zipball/1a0f9ab868e34b4375be42e873f60765e6632505",
- "reference": "1a0f9ab868e34b4375be42e873f60765e6632505",
- "shasum": ""
- },
- "require": {
- "php": "^7.4 || ^8.0"
- },
- "require-dev": {
- "automattic/vipwpcs": "^3.0",
- "php-stubs/wp-cli-stubs": "^2.12",
- "phpcompatibility/php-compatibility": "10.x-dev as 9.99.99",
- "phpcompatibility/phpcompatibility-wp": "^2.1",
- "phpstan/extension-installer": "^1.3",
- "phpstan/php-8-stubs": "^0.4.24",
- "phpstan/phpstan": "^2.1.22",
- "phpstan/phpstan-deprecation-rules": "^2.0.3",
- "phpunit/phpunit": "^9.6",
- "slevomat/coding-standard": "^8.0",
- "szepeviktor/phpstan-wordpress": "^2.0",
- "wp-phpunit/wp-phpunit": "^6.5",
- "wpackagist-plugin/plugin-check": "^1.6",
- "yoast/phpunit-polyfills": "^4.0"
- },
- "type": "library",
- "extra": {
- "installer-paths": {
- "vendor/{$vendor}/{$name}/": [
- "wpackagist-plugin/plugin-check"
- ]
- }
- },
- "autoload": {
- "psr-4": {
- "WP\\MCP\\": "includes/"
- }
- },
- "autoload-dev": {
- "psr-4": {
- "WP\\MCP\\Tests\\": [
- "tests/"
- ]
- }
- },
- "scripts": {
- "lint:php": [
- "phpcs"
- ],
- "lint:php:fix": [
- "phpcbf"
- ],
- "lint:php:stan": [
- "vendor/bin/phpstan analyse --memory-limit=1G"
- ],
- "test": [
- "phpunit --strict-coverage"
- ]
- },
- "license": [
- "GPL-2.0-or-later"
- ],
- "authors": [
- {
- "name": "WordPress AI Team",
- "homepage": "https://make.wordpress.org/ai/"
- }
- ],
- "description": "Adapter for Abilities API, letting WordPress abilities to be used as MCP tools, resources or prompts",
- "homepage": "https://github.com/wordpress/mcp-adapter",
- "keywords": [
- "abilities-api",
- "adapter",
- "ai",
- "api",
- "integration",
- "mcp",
- "model-context-protocol",
- "wordpress"
- ],
- "support": {
- "issues": "https://github.com/wordpress/mcp-adapter/issues",
- "source": "https://github.com/wordpress/mcp-adapter"
- },
- "time": "2025-12-09T06:56:51+00:00"
}
],
"packages-dev": [
@@ -5501,6 +5318,95 @@
"relative": true
}
},
+ {
+ "name": "wordpress/abilities-api",
+ "version": "v0.4.0",
+ "source": {
+ "type": "git",
+ "url": "git@github.com:WordPress/abilities-api.git",
+ "reference": "0759075aed37c4247adbf273bdebec096d52e825"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/WordPress/abilities-api/zipball/0759075aed37c4247adbf273bdebec096d52e825",
+ "reference": "0759075aed37c4247adbf273bdebec096d52e825",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.4 | ^8"
+ },
+ "require-dev": {
+ "automattic/vipwpcs": "^3.0",
+ "dealerdirect/phpcodesniffer-composer-installer": "^1.0",
+ "phpcompatibility/php-compatibility": "10.x-dev as 9.99.99",
+ "phpcompatibility/phpcompatibility-wp": "^2.1",
+ "phpstan/extension-installer": "^1.3",
+ "phpstan/phpstan": "^2.1.22",
+ "phpstan/phpstan-deprecation-rules": "^2.0.3",
+ "phpstan/phpstan-phpunit": "^2.0.3",
+ "phpunit/phpunit": "^8.5|^9.6",
+ "slevomat/coding-standard": "^8.0",
+ "squizlabs/php_codesniffer": "^3.9",
+ "szepeviktor/phpstan-wordpress": "^2.0.2",
+ "wp-coding-standards/wpcs": "^3.1",
+ "wp-phpunit/wp-phpunit": "^6.5",
+ "wpackagist-plugin/plugin-check": "^1.6",
+ "yoast/phpunit-polyfills": "^4.0"
+ },
+ "type": "library",
+ "extra": {
+ "installer-paths": {
+ "vendor/{$vendor}/{$name}/": [
+ "wpackagist-plugin/plugin-check"
+ ]
+ }
+ },
+ "autoload": {
+ "files": [
+ "includes/bootstrap.php"
+ ]
+ },
+ "scripts": {
+ "format": [
+ "phpcbf"
+ ],
+ "lint": [
+ "phpcs"
+ ],
+ "phpstan": [
+ "phpstan analyse --memory-limit=1G"
+ ],
+ "test": [
+ "phpunit --strict-coverage"
+ ],
+ "test-multisite": [
+ "WP_MULTISITE=1 phpunit --exclude-group=ms-excluded"
+ ]
+ },
+ "license": [
+ "GPL-2.0-or-later"
+ ],
+ "authors": [
+ {
+ "name": "WordPress AI Team",
+ "homepage": "https://make.wordpress.org/ai/"
+ }
+ ],
+ "description": "AI Abilities for WordPress.",
+ "homepage": "https://github.com/WordPress/abilities-api",
+ "keywords": [
+ "abilities",
+ "ai",
+ "api",
+ "llm",
+ "wordpress"
+ ],
+ "support": {
+ "issues": "https://github.com/WordPress/abilities-api/issues",
+ "source": "https://github.com/WordPress/abilities-api"
+ },
+ "time": "2025-10-29T05:35:31+00:00"
+ },
{
"name": "yoast/phpunit-polyfills",
"version": "2.0.5",
diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php
index 7bc1e79c33a..ff26224defc 100644
--- a/plugins/woocommerce/includes/class-woocommerce.php
+++ b/plugins/woocommerce/includes/class-woocommerce.php
@@ -21,7 +21,7 @@ use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableControlle
use Automattic\WooCommerce\Internal\DownloadPermissionsAdjuster;
use Automattic\WooCommerce\Internal\Features\FeaturesController;
use Automattic\WooCommerce\Internal\LegacyAssets\LegacySelect2UsageTracker;
-use Automattic\WooCommerce\Internal\MCP\MCPAdapterProvider;
+use Automattic\WooCommerce\Internal\MCP\McpEndpointRemoval;
use Automattic\WooCommerce\Internal\Abilities\AbilitiesRegistry;
use Automattic\WooCommerce\Internal\ProductAttributes\VisualAttributeTermAdmin;
use Automattic\WooCommerce\Internal\ProductAttributesLookup\DataRegenerator;
@@ -404,7 +404,7 @@ final class WooCommerce {
$container->get( DeferredEmailQueue::class );
$container->get( AddressProviderController::class );
$container->get( AbilitiesRegistry::class );
- $container->get( MCPAdapterProvider::class );
+ $container->get( McpEndpointRemoval::class );
$container->get( CouponCodeLookupInvalidator::class );
$container->get( ProductVersionStringInvalidator::class );
$container->get( OrdersVersionStringInvalidator::class );
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index d6d46e3861f..bbc1e3b7fc5 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -61062,30 +61062,6 @@ parameters:
count: 1
path: src/Internal/Logging/SafeGlobalFunctionProxy.php
- -
- message: '#^Call to an undefined method object\:\:create_server\(\)\.$#'
- identifier: method.notFound
- count: 1
- path: src/Internal/MCP/MCPAdapterProvider.php
-
- -
- message: '#^Method Automattic\\WooCommerce\\Internal\\MCP\\Transport\\WooCommerceRestTransport\:\:check_permission\(\) has parameter \$request with generic class WP_REST_Request but does not specify its types\: T$#'
- identifier: missingType.generics
- count: 1
- path: src/Internal/MCP/Transport/WooCommerceRestTransport.php
-
- -
- message: '#^Method Automattic\\WooCommerce\\Internal\\MCP\\Transport\\WooCommerceRestTransport\:\:validate_request\(\) has parameter \$request with generic class WP_REST_Request but does not specify its types\: T$#'
- identifier: missingType.generics
- count: 1
- path: src/Internal/MCP/Transport/WooCommerceRestTransport.php
-
- -
- message: '#^Parameter \#1 \$request of method Automattic\\WooCommerce\\Internal\\MCP\\Transport\\WooCommerceRestTransport\:\:validate_request\(\) expects WP_REST_Request, WP_REST_Request\|null given\.$#'
- identifier: argument.type
- count: 1
- path: src/Internal/MCP/Transport/WooCommerceRestTransport.php
-
-
message: '#^Method Automattic\\WooCommerce\\Internal\\OrderCouponDataMigrator\:\:convert_item\(\) has no return type specified\.$#'
identifier: missingType.return
diff --git a/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php b/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
index ad6b96c8209..fa779608aab 100644
--- a/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
+++ b/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
@@ -79,8 +79,6 @@ class AbilitiesLoader {
add_action( 'abilities_api_init', array( __CLASS__, 'register_abilities' ) );
add_action( 'wp_abilities_api_init', array( __CLASS__, 'register_abilities' ) );
- AbilitiesRestBridge::init();
-
self::$initialized = true;
}
diff --git a/plugins/woocommerce/src/Internal/Abilities/AbilitiesRestBridge.php b/plugins/woocommerce/src/Internal/Abilities/AbilitiesRestBridge.php
index d90b2a77668..c27795252b9 100644
--- a/plugins/woocommerce/src/Internal/Abilities/AbilitiesRestBridge.php
+++ b/plugins/woocommerce/src/Internal/Abilities/AbilitiesRestBridge.php
@@ -7,123 +7,36 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\Internal\Abilities;
-use Automattic\WooCommerce\Internal\Abilities\REST\RestAbilityFactory;
-use Automattic\WooCommerce\Internal\MCP\MCPAdapterProvider;
+use Automattic\WooCommerce\Internal\MCP\McpEndpointRemoval;
defined( 'ABSPATH' ) || exit;
/**
- * Abilities REST Bridge class for WooCommerce.
+ * Formerly registered REST-derived abilities for the WooCommerce MCP endpoint.
*
- * Configuration-driven registry that exposes REST endpoints as WordPress abilities.
- * Each ability is explicitly configured with ID, label, description, and operation.
+ * Kept so existing callers don't fatal. The methods do nothing except report incorrect usage.
+ *
+ * @deprecated 11.3.0 Removed along with the WooCommerce MCP endpoint. Use the WooCommerce abilities registered through the WordPress Abilities API.
*/
class AbilitiesRestBridge {
/**
- * Get REST controller configurations with explicit IDs, labels, and descriptions.
- *
- * @return array Controller configurations.
- */
- private static function get_configurations(): array {
- return array(
- array(
- 'controller' => \WC_REST_Products_Controller::class,
- 'route' => '/wc/v3/products',
- 'abilities' => array(
- array(
- 'id' => 'woocommerce/products-list',
- 'operation' => 'list',
- 'label' => __( 'List Products', 'woocommerce' ),
- 'description' => __( 'Retrieve a paginated list of products with optional filters for status, category, price range, and other attributes.', 'woocommerce' ),
- ),
- array(
- 'id' => 'woocommerce/products-get',
- 'operation' => 'get',
- 'label' => __( 'Get Product', 'woocommerce' ),
- 'description' => __( 'Retrieve detailed information about a single product by ID, including price, description, images, and metadata.', 'woocommerce' ),
- ),
- array(
- 'id' => 'woocommerce/products-create',
- 'operation' => 'create',
- 'label' => __( 'Create Product', 'woocommerce' ),
- 'description' => __( 'Create a new product with name, price, description, and other product attributes.', 'woocommerce' ),
- ),
- array(
- 'id' => 'woocommerce/products-update',
- 'operation' => 'update',
- 'label' => __( 'Update Product', 'woocommerce' ),
- 'description' => __( 'Update an existing product by modifying its attributes such as price, stock, description, or metadata.', 'woocommerce' ),
- ),
- array(
- 'id' => 'woocommerce/products-delete',
- 'operation' => 'delete',
- 'label' => __( 'Delete Product', 'woocommerce' ),
- 'description' => __( 'Permanently delete a product from the store. This action cannot be undone.', 'woocommerce' ),
- ),
- ),
- ),
- array(
- 'controller' => \WC_REST_Orders_Controller::class,
- 'route' => '/wc/v3/orders',
- 'abilities' => array(
- array(
- 'id' => 'woocommerce/orders-list',
- 'operation' => 'list',
- 'label' => __( 'List Orders', 'woocommerce' ),
- 'description' => __( 'Retrieve a paginated list of orders with optional filters for status, customer, date range, and other criteria.', 'woocommerce' ),
- ),
- array(
- 'id' => 'woocommerce/orders-get',
- 'operation' => 'get',
- 'label' => __( 'Get Order', 'woocommerce' ),
- 'description' => __( 'Retrieve detailed information about a single order by ID, including line items, customer details, and payment information.', 'woocommerce' ),
- ),
- array(
- 'id' => 'woocommerce/orders-create',
- 'operation' => 'create',
- 'label' => __( 'Create Order', 'woocommerce' ),
- 'description' => __( 'Create a new order with customer information, line items, shipping details, and payment information.', 'woocommerce' ),
- ),
- array(
- 'id' => 'woocommerce/orders-update',
- 'operation' => 'update',
- 'label' => __( 'Update Order', 'woocommerce' ),
- 'description' => __( 'Update an existing order by modifying status, customer information, line items, or other order details.', 'woocommerce' ),
- ),
- ),
- ),
- );
- }
-
- /**
- * Initialize the ability registration.
+ * Formerly hooked the REST-derived ability registration.
*
* @internal
+ *
+ * @deprecated 11.3.0
*/
final public static function init(): void {
- /*
- * Register abilities when Abilities API is ready.
- * Support both old (pre-6.9) and new (6.9+) action names.
- */
- add_action( 'abilities_api_init', array( __CLASS__, 'register_abilities' ) );
- add_action( 'wp_abilities_api_init', array( __CLASS__, 'register_abilities' ) );
+ McpEndpointRemoval::doing_it_wrong( __METHOD__ );
}
/**
- * Register all configured abilities.
+ * Formerly registered the REST-derived abilities.
+ *
+ * @deprecated 11.3.0
*/
public static function register_abilities(): void {
- // Only register abilities if this is an MCP endpoint request.
- // We check here (on abilities_api_init action) rather than earlier
- // because REST request detection requires the WordPress REST infrastructure
- // to be fully initialized.
- if ( ! MCPAdapterProvider::is_mcp_request() ) {
- return;
- }
-
- foreach ( self::get_configurations() as $config ) {
- RestAbilityFactory::register_controller_abilities( $config );
- }
+ McpEndpointRemoval::doing_it_wrong( __METHOD__ );
}
}
diff --git a/plugins/woocommerce/src/Internal/Abilities/REST/RestAbility.php b/plugins/woocommerce/src/Internal/Abilities/REST/RestAbility.php
index b91c0d16206..bd179c742ec 100644
--- a/plugins/woocommerce/src/Internal/Abilities/REST/RestAbility.php
+++ b/plugins/woocommerce/src/Internal/Abilities/REST/RestAbility.php
@@ -7,6 +7,8 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\Internal\Abilities\REST;
+use Automattic\WooCommerce\Internal\MCP\McpEndpointRemoval;
+
defined( 'ABSPATH' ) || exit;
/**
@@ -16,9 +18,22 @@ defined( 'ABSPATH' ) || exit;
* to handle the discrepancies between WooCommerce REST API schemas and
* actual output. This is necessary because WooCommerce schemas are often
* incomplete or inaccurate regarding nullable fields and type variations.
+ *
+ * @deprecated 11.3.0 Removed along with the WooCommerce MCP endpoint's REST-derived abilities. Still works, but reports incorrect usage.
*/
class RestAbility extends \WP_Ability {
+ /**
+ * Constructor.
+ *
+ * @param string $name The ability name.
+ * @param array $args The ability arguments.
+ */
+ public function __construct( string $name, array $args ) {
+ McpEndpointRemoval::doing_it_wrong( __CLASS__ );
+ parent::__construct( $name, $args );
+ }
+
/**
* Skip output validation for REST abilities.
*
diff --git a/plugins/woocommerce/src/Internal/Abilities/REST/RestAbilityFactory.php b/plugins/woocommerce/src/Internal/Abilities/REST/RestAbilityFactory.php
index b4fc74f22b0..9efb679b78e 100644
--- a/plugins/woocommerce/src/Internal/Abilities/REST/RestAbilityFactory.php
+++ b/plugins/woocommerce/src/Internal/Abilities/REST/RestAbilityFactory.php
@@ -7,668 +7,35 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\Internal\Abilities\REST;
-use Automattic\WooCommerce\Internal\MCP\Transport\WooCommerceRestTransport;
+use Automattic\WooCommerce\Internal\MCP\McpEndpointRemoval;
defined( 'ABSPATH' ) || exit;
/**
- * Factory class for creating abilities from REST controllers.
+ * Formerly created abilities from WooCommerce REST controllers for the WooCommerce MCP endpoint.
*
- * Handles the conversion of WooCommerce REST API endpoints into WordPress abilities
- * that can be consumed by MCP or other systems.
+ * Kept so existing callers don't fatal. The methods do nothing except report incorrect usage.
+ *
+ * @deprecated 11.3.0 Removed along with the WooCommerce MCP endpoint. Use the WooCommerce abilities registered through the WordPress Abilities API.
*/
class RestAbilityFactory {
/**
- * Metadata key that marks REST-derived abilities for deprecated WooCommerce MCP exposure.
- */
- public const EXPOSE_IN_DEPRECATED_MCP_META_KEY = 'expose_in_deprecated_woocommerce_mcp';
-
- /**
- * Register abilities for a REST controller based on configuration.
- *
- * @param array $config Controller configuration containing controller class and abilities array.
- */
- public static function register_controller_abilities( array $config ): void {
- $controller_class = $config['controller'];
-
- if ( ! class_exists( $controller_class ) ) {
- return;
- }
-
- $controller = new $controller_class();
-
- foreach ( $config['abilities'] as $ability_config ) {
- self::register_single_ability( $controller, $ability_config, $config['route'] );
- }
- }
-
- /**
- * Register a single ability.
- *
- * @param object $controller REST controller instance.
- * @param array $ability_config Ability configuration array.
- * @param string $route REST route for this controller.
- */
- private static function register_single_ability( $controller, array $ability_config, string $route ): void {
- // Only proceed if wp_register_ability function exists.
- if ( ! function_exists( 'wp_register_ability' ) ) {
- return;
- }
-
- try {
- $ability_args = array(
- 'label' => $ability_config['label'],
- 'description' => $ability_config['description'],
- 'category' => 'woocommerce-rest',
- 'input_schema' => self::get_schema_for_operation( $controller, $ability_config['operation'] ),
- 'output_schema' => self::get_output_schema( $controller, $ability_config['operation'] ),
- 'execute_callback' => function ( $input ) use ( $controller, $ability_config, $route ) {
- return self::execute_operation( $controller, $ability_config['operation'], $input, $route );
- },
- 'permission_callback' => function () use ( $controller, $ability_config ) {
- return self::check_permission( $controller, $ability_config['operation'] );
- },
- 'ability_class' => RestAbility::class,
- 'meta' => array(
- 'show_in_rest' => true,
- self::EXPOSE_IN_DEPRECATED_MCP_META_KEY => true,
- ),
- );
-
- // Add readonly annotation for GET operations (list and get).
- if ( in_array( $ability_config['operation'], array( 'list', 'get' ), true ) ) {
- $ability_args['meta']['annotations'] = array(
- 'readonly' => true,
- );
- }
-
- wp_register_ability( $ability_config['id'], $ability_args );
- } catch ( \Throwable $e ) {
- // Log the error for debugging but don't break the registration of other abilities.
- if ( function_exists( 'wc_get_logger' ) ) {
- wc_get_logger()->error(
- "Failed to register ability {$ability_config['id']}: " . $e->getMessage(),
- array( 'source' => 'woocommerce-rest-abilities' )
- );
- }
- }
- }
-
- /**
- * Get input schema based on operation type.
- *
- * @param object $controller REST controller instance.
- * @param string $operation Operation type (list, get, create, update, delete).
- * @return array Input schema array.
- */
- private static function get_schema_for_operation( $controller, string $operation ): array {
- switch ( $operation ) {
- case 'list':
- // Use controller's collection parameters.
- if ( method_exists( $controller, 'get_collection_params' ) ) {
- return self::sanitize_args_to_schema( $controller->get_collection_params() );
- }
- break;
-
- case 'create':
- // Use controller's creatable schema.
- if ( method_exists( $controller, 'get_endpoint_args_for_item_schema' ) ) {
- $args = $controller->get_endpoint_args_for_item_schema( \WP_REST_Server::CREATABLE );
- return self::sanitize_args_to_schema( $args );
- }
- break;
-
- case 'update':
- // Use controller's editable schema + ID.
- if ( method_exists( $controller, 'get_endpoint_args_for_item_schema' ) ) {
- $args = $controller->get_endpoint_args_for_item_schema( \WP_REST_Server::EDITABLE );
- $schema = self::sanitize_args_to_schema( $args );
-
- // Add ID field for update operations.
- $schema['properties']['id'] = array(
- 'type' => 'integer',
- 'description' => __( 'Unique identifier for the resource', 'woocommerce' ),
- );
-
- // Ensure ID is required.
- if ( ! isset( $schema['required'] ) ) {
- $schema['required'] = array();
- }
- if ( ! in_array( 'id', $schema['required'], true ) ) {
- $schema['required'][] = 'id';
- }
-
- return $schema;
- }
- break;
-
- case 'get':
- case 'delete':
- // Only need ID.
- return array(
- 'type' => 'object',
- 'properties' => array(
- 'id' => array(
- 'type' => 'integer',
- 'description' => __( 'Unique identifier for the resource', 'woocommerce' ),
- ),
- ),
- 'required' => array( 'id' ),
- );
- }
-
- // Fallback.
- return array( 'type' => 'object' );
- }
-
- /**
- * Valid JSON Schema types.
- *
- * @var array
- */
- private static $valid_types = array( 'string', 'number', 'integer', 'boolean', 'object', 'array', 'null' );
-
- /**
- * Subset of {@see self::$valid_types} considered scalar for output relaxation.
- *
- * When a field is declared as one of these in the source REST schema, output
- * validation widens it to {@see self::OUTPUT_SCALAR_UNION}.
- */
- private const SCALAR_TYPES = array( 'string', 'integer', 'number', 'boolean' );
-
- /**
- * Union we emit on output for any field originally declared as a single scalar.
- *
- * Covers three failure modes seen in the wild on WooCommerce REST responses:
- * 1. The field may legitimately be unset / null (e.g. `low_stock_amount`).
- * 2. The declared scalar disagrees with the scalar actually returned (e.g.
- * `shipping_class_id` declared `string`, returned as `int`).
- * 3. The declared scalar is returned as a non-scalar — most notably
- * `meta_data[].display_value`, declared `string` but routinely an array
- * when the underlying meta value is itself an array (variation
- * attributes, serialized custom data, etc.).
- *
- * The union is effectively "any JSON type." That makes the type constraint
- * a no-op for declared scalars, but it remains explicit (so validators that
- * require a `type` key are still satisfied) and contained to the MCP output
- * schema path. The alternative is per-controller schema fixes scattered
- * across legacy REST code.
- */
- private const OUTPUT_SCALAR_UNION = array( 'string', 'integer', 'number', 'boolean', 'array', 'object', 'null' );
-
- /**
- * Sanitize WordPress REST args to valid JSON Schema format.
- *
- * Converts WordPress REST API argument arrays to JSON Schema by:
- * - Removing PHP callbacks (sanitize_callback, validate_callback)
- * - Converting 'required' from boolean-per-field to array-of-names
- * - Removing WordPress-specific non-schema fields
- * - Preserving valid JSON Schema properties
- * - Converting invalid types (date-time, mixed, action) to valid JSON Schema
- * - Recursively sanitizing nested properties and items
- * - Deduplicating enum values
- *
- * @param array $args WordPress REST API arguments array.
- * @return array Valid JSON Schema object.
- */
- private static function sanitize_args_to_schema( array $args ): array {
- $properties = array();
- $required = array();
-
- foreach ( $args as $key => $arg ) {
- $property = array();
-
- // Copy valid JSON Schema fields, normalizing types.
- if ( isset( $arg['type'] ) ) {
- $property = self::normalize_type( $property, $arg['type'] );
- }
- if ( isset( $arg['description'] ) ) {
- $property['description'] = $arg['description'];
- }
- if ( isset( $arg['default'] ) ) {
- $property['default'] = $arg['default'];
- }
- if ( isset( $arg['enum'] ) ) {
- $property['enum'] = self::dedupe_enum( $arg['enum'] );
- }
- if ( isset( $arg['items'] ) ) {
- $property['items'] = self::sanitize_schema( $arg['items'] );
- }
- if ( isset( $arg['minimum'] ) ) {
- $property['minimum'] = $arg['minimum'];
- }
- if ( isset( $arg['maximum'] ) ) {
- $property['maximum'] = $arg['maximum'];
- }
- if ( isset( $arg['format'] ) && ! isset( $property['format'] ) ) {
- $property['format'] = $arg['format'];
- }
- if ( isset( $arg['properties'] ) ) {
- $property['properties'] = self::sanitize_schema_properties( $arg['properties'] );
- }
-
- // Convert readonly to readOnly (JSON Schema format).
- if ( isset( $arg['readonly'] ) && $arg['readonly'] ) {
- $property['readOnly'] = true;
- }
-
- // Collect required fields.
- if ( isset( $arg['required'] ) && true === $arg['required'] ) {
- $required[] = $key;
- }
-
- $properties[ $key ] = $property;
- }
-
- $schema = array(
- 'type' => 'object',
- 'properties' => $properties,
- );
-
- if ( ! empty( $required ) ) {
- $schema['required'] = array_unique( $required );
- }
-
- return $schema;
- }
-
- /**
- * Recursively sanitize a JSON Schema node.
- *
- * Fixes invalid types, deduplicates enums, and recurses into
- * nested properties and items.
- *
- * @param array $schema A JSON Schema node.
- * @return array Sanitized schema node.
- */
- private static function sanitize_schema( array $schema ): array {
- if ( isset( $schema['type'] ) ) {
- $schema = self::normalize_type( $schema, $schema['type'] );
- }
-
- if ( isset( $schema['enum'] ) ) {
- $schema['enum'] = self::dedupe_enum( $schema['enum'] );
- }
-
- // Remove WordPress-style boolean 'required' — JSON Schema requires an array.
- if ( isset( $schema['required'] ) && is_bool( $schema['required'] ) ) {
- unset( $schema['required'] );
- }
-
- if ( isset( $schema['properties'] ) && is_array( $schema['properties'] ) ) {
- // Collect required fields from nested boolean 'required' before sanitizing.
- $required = array();
- foreach ( $schema['properties'] as $key => $property ) {
- if ( is_array( $property ) && isset( $property['required'] ) && true === $property['required'] ) {
- $required[] = $key;
- }
- }
- if ( ! empty( $required ) ) {
- $schema['required'] = isset( $schema['required'] ) && is_array( $schema['required'] )
- ? array_values( array_unique( array_merge( $schema['required'], $required ) ) )
- : $required;
- }
-
- $schema['properties'] = self::sanitize_schema_properties( $schema['properties'] );
- }
-
- if ( isset( $schema['items'] ) && is_array( $schema['items'] ) ) {
- if ( isset( $schema['items'][0] ) ) {
- // Tuple form: sanitize each positional entry.
- foreach ( $schema['items'] as $index => $entry ) {
- if ( is_array( $entry ) ) {
- $schema['items'][ $index ] = self::sanitize_schema( $entry );
- }
- }
- } else {
- $schema['items'] = self::sanitize_schema( $schema['items'] );
- }
- }
-
- return $schema;
- }
-
- /**
- * Sanitize a map of JSON Schema properties.
- *
- * @param array $properties Map of property name to schema.
- * @return array Sanitized properties map.
- */
- private static function sanitize_schema_properties( array $properties ): array {
- foreach ( $properties as $key => $property ) {
- if ( is_array( $property ) ) {
- $properties[ $key ] = self::sanitize_schema( $property );
- }
- }
- return $properties;
- }
-
- /**
- * Normalize a schema type value.
- *
- * Handles both string types ('string', 'date-time', etc.) and
- * array types (['string', 'null']) used for nullable fields.
- *
- * @param array $schema The schema node being built.
- * @param string|array $type The type value to normalize.
- * @return array Schema with normalized type (or type removed if all invalid).
- */
- private static function normalize_type( array $schema, $type ): array {
- if ( is_string( $type ) ) {
- if ( 'date-time' === $type ) {
- $schema['type'] = 'string';
- if ( ! isset( $schema['format'] ) ) {
- $schema['format'] = 'date-time';
- }
- } elseif ( 'action' === $type ) {
- $schema['type'] = 'object';
- } elseif ( in_array( $type, self::$valid_types, true ) ) {
- $schema['type'] = $type;
- } else {
- unset( $schema['type'] );
- }
- return $schema;
- }
-
- if ( is_array( $type ) ) {
- $normalized = array();
- foreach ( $type as $single ) {
- if ( ! is_string( $single ) ) {
- continue;
- }
- if ( 'date-time' === $single ) {
- $single = 'string';
- if ( ! isset( $schema['format'] ) ) {
- $schema['format'] = 'date-time';
- }
- } elseif ( 'action' === $single ) {
- $single = 'object';
- } elseif ( ! in_array( $single, self::$valid_types, true ) ) {
- continue;
- }
- $normalized[] = $single;
- }
- $normalized = array_values( array_unique( $normalized ) );
- if ( empty( $normalized ) ) {
- unset( $schema['type'] );
- } elseif ( 1 === count( $normalized ) ) {
- $schema['type'] = $normalized[0];
- } else {
- $schema['type'] = $normalized;
- }
- return $schema;
- }
-
- // Non-string, non-array type — remove it.
- unset( $schema['type'] );
- return $schema;
- }
-
- /**
- * Remove duplicate enum values while preserving order.
- *
- * Uses JSON encoding for fingerprinting to correctly handle
- * mixed scalar types (1 vs '1'), nulls, and complex values (arrays).
- *
- * @param array $values Enum values.
- * @return array Deduplicated enum values.
- */
- private static function dedupe_enum( array $values ): array {
- $seen = array();
- $unique = array();
- foreach ( $values as $value ) {
- $fingerprint = wp_json_encode( $value );
- if ( isset( $seen[ $fingerprint ] ) ) {
- continue;
- }
- $seen[ $fingerprint ] = true;
- $unique[] = $value;
- }
- return $unique;
- }
-
- /**
- * Recursively relax an output schema so it accepts the shapes WooCommerce REST
- * controllers actually return.
- *
- * Used on output schemas only — input schemas keep their tighter constraints so
- * MCP clients still get useful hints when formatting tool calls. Must run AFTER
- * {@see self::sanitize_schema()}, which converts the `date-time` pseudo-type to
- * `type: "string"` + `format: "date-time"` — this method then strips the format.
- *
- * Relaxations:
- *
- * 1. `format: "date-time"` and `format: "uri"` are stripped. WooCommerce REST
- * date strings (e.g. `2025-11-24T16:31:43`) omit the timezone suffix RFC 3339
- * requires, and `format: "uri"` fields routinely return empty strings.
- * 2. Any `type` whose declared members are all scalars and/or `null` is
- * widened to {@see self::OUTPUT_SCALAR_UNION} — every JSON type plus
- * `null`. Applies to single scalars (`string`, `integer`, `number`,
- * `boolean`) and to pre-existing unions like `[integer, null]`. Fields
- * that declare any compound type (`object`, `array`) are left alone.
- * This is a deliberate accuracy tradeoff: many WooCommerce REST
- * controllers declare types that disagree with what they actually return
- * (e.g. `shipping_class_id` declared `string` but returned as `int`;
- * `low_stock_amount` declared `[integer, null]` but returned as `""`
- * when unset; `meta_data[].display_value` declared `string` but
- * routinely an array for variation attributes and serialized custom
- * meta). The alternative is per-controller schema fixes across legacy
- * code. Skipped inside `anyOf` / `oneOf` / `allOf` branches: widening
- * every branch breaks the "exactly one" rule for `oneOf`, and for
- * `anyOf` / `allOf` the schema author was explicit about admissible
- * shapes.
- *
- * Recurses into `properties`, `items` (single schema and tuple form),
- * `additionalProperties`, and the `anyOf` / `oneOf` / `allOf` combiners.
- *
- * @param array $schema A JSON Schema node.
- * @param bool $apply_null_union Whether to apply the scalar-to-nullable widening at this node.
- * False when recursing into combiner branches.
- * @return array Relaxed schema node.
- */
- private static function relax_output_schema_for_wc_quirks( array $schema, bool $apply_null_union = true ): array {
- if ( isset( $schema['format'] ) && in_array( $schema['format'], array( 'date-time', 'uri' ), true ) ) {
- unset( $schema['format'] );
- }
-
- if ( $apply_null_union && isset( $schema['type'] ) && self::should_widen_to_output_union( $schema['type'] ) ) {
- $schema['type'] = self::OUTPUT_SCALAR_UNION;
- }
-
- if ( isset( $schema['properties'] ) && is_array( $schema['properties'] ) ) {
- foreach ( $schema['properties'] as $key => $property ) {
- if ( is_array( $property ) ) {
- $schema['properties'][ $key ] = self::relax_output_schema_for_wc_quirks( $property, $apply_null_union );
- }
- }
- }
-
- if ( isset( $schema['items'] ) && is_array( $schema['items'] ) ) {
- if ( isset( $schema['items'][0] ) ) {
- // Tuple form: each numerically-indexed entry validates the array element at that position.
- foreach ( $schema['items'] as $index => $entry ) {
- if ( is_array( $entry ) ) {
- $schema['items'][ $index ] = self::relax_output_schema_for_wc_quirks( $entry, $apply_null_union );
- }
- }
- } else {
- $schema['items'] = self::relax_output_schema_for_wc_quirks( $schema['items'], $apply_null_union );
- }
- }
-
- if ( isset( $schema['additionalProperties'] ) && is_array( $schema['additionalProperties'] ) ) {
- $schema['additionalProperties'] = self::relax_output_schema_for_wc_quirks( $schema['additionalProperties'], $apply_null_union );
- }
-
- foreach ( array( 'anyOf', 'oneOf', 'allOf' ) as $combiner ) {
- if ( isset( $schema[ $combiner ] ) && is_array( $schema[ $combiner ] ) ) {
- foreach ( $schema[ $combiner ] as $index => $branch ) {
- if ( is_array( $branch ) ) {
- // Entering a combiner from anywhere disables null-union for the entire subtree.
- $schema[ $combiner ][ $index ] = self::relax_output_schema_for_wc_quirks( $branch, false );
- }
- }
- }
- }
-
- return $schema;
- }
-
- /**
- * Decide whether an output-schema `type` should be widened to the full union.
- *
- * Widens when the declared type is either a single scalar (`integer`, `string`, etc.)
- * or an array union whose members are all scalars and/or `null`. Leaves the
- * declaration alone if any compound type (`object`, `array`) appears, since the
- * schema author was explicit about admitting a structured value.
- *
- * Handles the WC quirk where fields declared as `[integer, null]`
- * (e.g. `low_stock_amount`) are returned as empty strings when unset, which
- * neither member of the declared union admits.
- *
- * @param mixed $type Schema `type` value (string, array, or other).
- * @return bool True if the type should be replaced with {@see self::OUTPUT_SCALAR_UNION}.
- */
- private static function should_widen_to_output_union( $type ): bool {
- if ( is_string( $type ) ) {
- return in_array( $type, self::SCALAR_TYPES, true );
- }
-
- if ( ! is_array( $type ) || empty( $type ) ) {
- return false;
- }
-
- $widenable = array_merge( self::SCALAR_TYPES, array( 'null' ) );
- foreach ( $type as $member ) {
- if ( ! is_string( $member ) || ! in_array( $member, $widenable, true ) ) {
- return false;
- }
- }
-
- return true;
- }
-
- /**
- * Get output schema for operation.
+ * Metadata key that marked abilities for the removed WooCommerce MCP endpoint. WooCommerce no longer reads it.
*
- * @param object $controller REST controller instance.
- * @param string $operation Operation type.
- * @return array Output schema array.
+ * @deprecated 11.3.0
*/
- private static function get_output_schema( $controller, string $operation ): array {
- if ( method_exists( $controller, 'get_item_schema' ) ) {
- $schema = self::sanitize_schema( $controller->get_item_schema() );
- $schema = self::relax_output_schema_for_wc_quirks( $schema );
-
- if ( 'list' === $operation ) {
- // For list operations, return object wrapping array of items.
- // This ensures MCP compatibility while maintaining REST structure.
- return array(
- 'type' => 'object',
- 'properties' => array(
- 'data' => array(
- 'type' => 'array',
- 'items' => $schema,
- ),
- ),
- );
- } elseif ( 'delete' === $operation ) {
- // For delete operations, return simple confirmation.
- return array(
- 'type' => 'object',
- 'properties' => array(
- 'deleted' => array( 'type' => 'boolean' ),
- 'previous' => $schema,
- ),
- );
- }
-
- // For get, create, update operations.
- return $schema;
- }
-
- return array( 'type' => 'object' );
- }
-
- /**
- * Execute the REST operation.
- *
- * @param object $controller REST controller instance.
- * @param string $operation Operation type.
- * @param array $input Input parameters.
- * @param string $route REST route for this controller.
- * @return mixed Operation result.
- */
- private static function execute_operation( $controller, string $operation, array $input, string $route ) {
- $method = self::get_http_method_for_operation( $operation );
-
- // Build final route - add ID for single item operations.
- $request_route = $route;
- if ( isset( $input['id'] ) && in_array( $operation, array( 'get', 'update', 'delete' ), true ) ) {
- $request_route .= '/' . intval( $input['id'] );
- unset( $input['id'] );
- }
-
- // Create REST request.
- $request = new \WP_REST_Request( $method, $request_route );
- foreach ( $input as $key => $value ) {
- $request->set_param( $key, $value );
- }
-
- // Dispatch through REST API for proper validation and permissions.
- $response = rest_do_request( $request );
-
- if ( is_wp_error( $response ) ) {
- return $response;
- }
-
- $data = $response instanceof \WP_REST_Response ? $response->get_data() : $response;
-
- // For list operations, wrap in data object to match schema.
- if ( 'list' === $operation ) {
- return array( 'data' => $data );
- }
-
- return $data;
- }
+ public const EXPOSE_IN_DEPRECATED_MCP_META_KEY = 'expose_in_deprecated_woocommerce_mcp';
/**
- * Get HTTP method for a given operation type.
+ * Formerly registered abilities for a REST controller.
*
- * @param string $operation Operation type (list, get, create, update, delete).
- * @return string HTTP method (GET, POST, PUT, DELETE).
- */
- private static function get_http_method_for_operation( string $operation ): string {
- $method_map = array(
- 'list' => 'GET',
- 'get' => 'GET',
- 'create' => 'POST',
- 'update' => 'PUT',
- 'delete' => 'DELETE',
- );
- return $method_map[ $operation ] ?? 'GET';
- }
-
- /**
- * Check permissions for MCP operations.
+ * @deprecated 11.3.0
*
- * @param object $controller REST controller instance.
- * @param string $operation Operation type.
- * @return bool Whether permission is granted.
+ * @param array $config Controller configuration. Ignored.
*/
- private static function check_permission( $controller, string $operation ): bool {
- // Get HTTP method for the operation.
- $method = self::get_http_method_for_operation( $operation );
-
- /**
- * Filter to check REST ability permissions for HTTP method.
- *
- * @since 10.3.0
- * @param bool $allowed Whether the operation is allowed. Default false.
- * @param string $method HTTP method (GET, POST, PUT, DELETE).
- * @param object $controller REST controller instance.
- */
- return apply_filters( 'woocommerce_check_rest_ability_permissions_for_method', false, $method, $controller );
+ public static function register_controller_abilities( array $config ): void {
+ unset( $config ); // Kept for backward compatibility.
+ McpEndpointRemoval::doing_it_wrong( __METHOD__ );
}
}
diff --git a/plugins/woocommerce/src/Internal/Features/FeaturesController.php b/plugins/woocommerce/src/Internal/Features/FeaturesController.php
index 87fdc15f74f..66ab0b744c5 100644
--- a/plugins/woocommerce/src/Internal/Features/FeaturesController.php
+++ b/plugins/woocommerce/src/Internal/Features/FeaturesController.php
@@ -635,12 +635,17 @@ class FeaturesController {
),
'mcp_integration' => array(
'name' => __( 'WooCommerce MCP', 'woocommerce' ),
- 'description' => $this->get_mcp_integration_description(),
- 'enabled_by_default' => false,
- 'disable_ui' => false,
+ 'description' => __(
+ 'The WooCommerce MCP endpoint was removed. Use the MCP Adapter plugin to access WooCommerce abilities over MCP.',
+ 'woocommerce'
+ ),
'is_experimental' => true,
+ 'enabled_by_default' => false,
+ 'disable_ui' => true,
+ 'skip_compatibility_checks' => true,
'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
- 'is_legacy' => false,
+ 'deprecated_since' => '11.3.0',
+ 'deprecated_value' => false,
),
'ucp' => array(
'name' => __( 'Universal Commerce Protocol', 'woocommerce' ),
@@ -787,44 +792,6 @@ class FeaturesController {
}
}
- /**
- * Generate the description for the MCP integration feature.
- *
- * @return string The feature description with conditional permalink warning and documentation link.
- */
- private function get_mcp_integration_description() {
- $base_description = __( 'Enable WooCommerce MCP (Model Context Protocol) for AI-powered store operations. AI-generated results and actions can be unpredictable - please review before executing in your store.', 'woocommerce' );
-
- // Check permalink structure requirement.
- $permalink_structure = get_option( 'permalink_structure' );
- if ( empty( $permalink_structure ) ) {
- $permalinks_url = admin_url( 'options-permalink.php' );
- $permalink_warning = sprintf(
- '<br><br><strong>%s:</strong> %s <a href="%s">%s</a>',
- __( 'Configuration Required', 'woocommerce' ),
- __( 'WordPress permalinks must be set to anything other than "Plain" for MCP to work.', 'woocommerce' ),
- $permalinks_url,
- __( 'Configure Permalinks', 'woocommerce' )
- );
- // Add documentation link to permalink warning.
- $documentation_link = sprintf(
- ' <a href="%s" target="_blank">%s</a>',
- 'https://github.com/woocommerce/woocommerce/blob/trunk/docs/features/mcp/README.md',
- __( 'Learn more', 'woocommerce' )
- );
- return $base_description . $permalink_warning . $documentation_link;
- }
-
- // Add documentation link.
- $documentation_link = sprintf(
- ' <a href="%s" target="_blank">%s</a>',
- 'https://github.com/woocommerce/woocommerce/blob/trunk/docs/features/mcp/README.md',
- __( 'Learn more', 'woocommerce' )
- );
-
- return $base_description . $documentation_link;
- }
-
/**
* Function to trigger the (now deprecated) 'woocommerce_register_feature_definitions' hook.
*
diff --git a/plugins/woocommerce/src/Internal/MCP/MCPAdapterProvider.php b/plugins/woocommerce/src/Internal/MCP/MCPAdapterProvider.php
index ffd54b213c7..b8e3171b678 100644
--- a/plugins/woocommerce/src/Internal/MCP/MCPAdapterProvider.php
+++ b/plugins/woocommerce/src/Internal/MCP/MCPAdapterProvider.php
@@ -7,234 +7,82 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\Internal\MCP;
-use Automattic\WooCommerce\Utilities\FeaturesUtil;
-use Automattic\WooCommerce\Internal\Abilities\AbilitiesRegistry;
-use Automattic\WooCommerce\Internal\Abilities\REST\RestAbilityFactory;
-use Automattic\WooCommerce\Internal\MCP\Transport\WooCommerceRestTransport;
-
defined( 'ABSPATH' ) || exit;
/**
- * MCP Adapter Provider class for WooCommerce.
+ * Formerly provided the WooCommerce MCP endpoint (woocommerce/mcp).
+ *
+ * Kept so existing callers don't fatal. The methods do nothing except report incorrect usage.
*
- * Manages MCP (Model Context Protocol) adapter initialization and server configuration.
- * Abilities should be registered separately using the WordPress Abilities API.
+ * @deprecated 11.3.0 The WooCommerce MCP endpoint was removed. Use the MCP Adapter plugin to access WooCommerce abilities over MCP.
*/
class MCPAdapterProvider {
/**
- * MCP server namespace.
+ * Namespace of the removed WooCommerce MCP endpoint.
*
* @var string
*/
const MCP_NAMESPACE = 'woocommerce';
/**
- * MCP server route.
+ * Route of the removed WooCommerce MCP endpoint.
*
* @var string
*/
const MCP_ROUTE = 'mcp';
- /**
- * Whether MCP adapter is initialized.
- *
- * @var bool
- */
- private bool $initialized = false;
-
/**
* Constructor.
+ *
+ * @deprecated 11.3.0
*/
public function __construct() {
- /*
- * Hook into rest_api_init with priority 10 to initialize only on REST API requests.
- * MCP adapter registers on rest_api_init with priority 20000, so we initialize earlier.
- * This prevents unnecessary MCP initialization on favicon, cron, or admin requests.
- */
- add_action( 'rest_api_init', array( $this, 'maybe_initialize' ), 10 );
- }
-
- /**
- * Check feature flag and initialize MCP adapter if enabled.
- */
- public function maybe_initialize(): void {
- // Check if MCP integration feature is enabled.
- if ( ! FeaturesUtil::feature_is_enabled( 'mcp_integration' ) ) {
- return;
- }
-
- // Prevent double initialization.
- if ( $this->initialized ) {
- return;
- }
-
- $this->initialize_mcp_adapter();
- $this->register_hooks();
- $this->initialized = true;
- }
-
- /**
- * Initialize the MCP adapter.
- */
- private function initialize_mcp_adapter(): void {
- // Check if MCP adapter class exists (should be autoloaded by WooCommerce's composer).
- if ( ! class_exists( 'WP\MCP\Core\McpAdapter' ) ) {
- if ( function_exists( 'wc_get_logger' ) ) {
- wc_get_logger()->warning(
- 'MCP adapter class not found. Skipping MCP initialization.',
- array( 'source' => 'woocommerce-mcp' )
- );
- }
- return;
- }
-
- // Initialize the MCP adapter instance - this triggers the rest_api_init hook registration.
- \WP\MCP\Core\McpAdapter::instance();
- }
-
- /**
- * Register WordPress hooks for MCP adapter.
- */
- private function register_hooks(): void {
- // Initialize MCP server when MCP adapter is ready.
- add_action( 'mcp_adapter_init', array( $this, 'initialize_mcp_server' ) );
+ McpEndpointRemoval::doing_it_wrong( __METHOD__ );
}
/**
- * Initialize MCP server.
+ * Formerly initialized the MCP adapter when the WooCommerce MCP feature was enabled.
*
- * @param object $adapter MCP adapter instance.
+ * @deprecated 11.3.0
*/
- public function initialize_mcp_server( $adapter ): void {
- // Get filtered abilities for MCP server.
- $abilities_ids = $this->get_woocommerce_mcp_abilities();
-
- // Bail if no abilities are available.
- if ( empty( $abilities_ids ) ) {
- return;
- }
-
- try {
- // Create MCP server.
- $adapter->create_server(
- 'woocommerce-mcp',
- self::MCP_NAMESPACE,
- self::MCP_ROUTE,
- __( 'WooCommerce MCP Server', 'woocommerce' ),
- __( 'AI-accessible WooCommerce operations via MCP', 'woocommerce' ),
- '1.0.0',
- array( WooCommerceRestTransport::class ),
- \WP\MCP\Infrastructure\ErrorHandling\ErrorLogMcpErrorHandler::class,
- \WP\MCP\Infrastructure\Observability\NullMcpObservabilityHandler::class,
- $abilities_ids,
- );
- } catch ( \Throwable $e ) {
- if ( function_exists( 'wc_get_logger' ) ) {
- wc_get_logger()->error(
- 'MCP server initialization failed: ' . $e->getMessage(),
- array( 'source' => 'woocommerce-mcp' )
- );
- }
- }
+ public function maybe_initialize(): void {
+ McpEndpointRemoval::doing_it_wrong( __METHOD__ );
}
/**
- * Get WooCommerce abilities for MCP server.
+ * Formerly created the WooCommerce MCP server.
*
- * Filters abilities to include only those explicitly exposed to the deprecated
- * WooCommerce MCP endpoint, with a filter to override inclusion decisions.
+ * @deprecated 11.3.0
*
- * @return array Array of ability IDs for MCP server.
+ * @param object $adapter MCP adapter instance. Ignored.
*/
- private function get_woocommerce_mcp_abilities(): array {
- // Get all abilities from the registry.
- $abilities_registry = wc_get_container()->get( AbilitiesRegistry::class );
- $all_abilities_ids = $abilities_registry->get_abilities_ids();
-
- // Filter abilities based on deprecated endpoint exposure metadata and custom filter.
- $mcp_abilities = array_filter(
- $all_abilities_ids,
- static function ( $ability_id ) {
- $include = self::should_include_ability_by_default( $ability_id );
-
- // Allow filter to override inclusion decision.
- /**
- * Filter to override MCP ability inclusion decision.
- *
- * @since 10.3.0
- *
- * @param bool $include Whether to include the ability by default. True when the ability has
- * `expose_in_deprecated_woocommerce_mcp => true` in its metadata
- * (set automatically on REST-derived abilities by RestAbilityFactory).
- * Migration note: this value no longer represents whether the ability uses
- * the `woocommerce/` namespace.
- * Return unchanged to keep the default, or return true/false to override.
- * @param string $ability_id The ability ID.
- */
- return apply_filters( 'woocommerce_mcp_include_ability', $include, $ability_id );
- }
- );
-
- // Re-index array.
- return array_values( $mcp_abilities );
+ public function initialize_mcp_server( $adapter ): void {
+ unset( $adapter ); // Kept for backward compatibility.
+ McpEndpointRemoval::doing_it_wrong( __METHOD__ );
}
/**
- * Check if an ability should be included in the deprecated WooCommerce MCP endpoint.
+ * Formerly reported whether the MCP adapter was initialized.
*
- * REST-derived abilities can opt in to the deprecated WooCommerce MCP endpoint.
- * Require explicit metadata so semantic/domain abilities can use WooCommerce
- * namespaces without expanding the deprecated MCP tool list.
- * This intentionally allows abilities from any namespace to opt in to the
- * deprecated endpoint while keeping namespace and transport exposure separate.
+ * @deprecated 11.3.0
*
- * @param string $ability_id Ability ID.
- * @return bool Whether to include the ability by default.
+ * @return bool Always false.
*/
- private static function should_include_ability_by_default( string $ability_id ): bool {
- // Keep the pre-check to avoid triggering _doing_it_wrong() for stale or mocked registry IDs.
- if ( function_exists( 'wp_get_ability' ) && function_exists( 'wp_has_ability' ) && wp_has_ability( $ability_id ) ) {
- $ability = wp_get_ability( $ability_id );
- if ( $ability ) {
- // Strict boolean required: truthy values like 1 or "true" are intentionally excluded.
- return true === $ability->get_meta_item(
- RestAbilityFactory::EXPOSE_IN_DEPRECATED_MCP_META_KEY,
- false
- );
- }
- }
-
+ public function is_initialized(): bool {
+ McpEndpointRemoval::doing_it_wrong( __METHOD__ );
return false;
}
/**
- * Check if MCP adapter is initialized.
+ * Formerly reported whether the current request was for the WooCommerce MCP endpoint.
*
- * @return bool Whether MCP adapter is initialized.
- */
- public function is_initialized(): bool {
- return $this->initialized;
- }
-
- /**
- * Check if the current request is for the MCP endpoint.
+ * @deprecated 11.3.0
*
- * @return bool True if this is an MCP endpoint request.
+ * @return bool Always false.
*/
public static function is_mcp_request(): bool {
- // Check if this is a REST request.
- if ( ! wp_is_serving_rest_request() ) {
- return false;
- }
-
- // Get the request URI.
- $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
-
- // Build the MCP endpoint path dynamically from constants.
- $mcp_endpoint = '/' . self::MCP_NAMESPACE . '/' . self::MCP_ROUTE;
-
- // Check if the request is for the MCP endpoint.
- return false !== strpos( $request_uri, $mcp_endpoint );
+ McpEndpointRemoval::doing_it_wrong( __METHOD__ );
+ return false;
}
}
diff --git a/plugins/woocommerce/src/Internal/MCP/McpEndpointRemoval.php b/plugins/woocommerce/src/Internal/MCP/McpEndpointRemoval.php
new file mode 100644
index 00000000000..ec2e748b0bb
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/MCP/McpEndpointRemoval.php
@@ -0,0 +1,311 @@
+<?php
+/**
+ * McpEndpointRemoval class file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Internal\MCP;
+
+use Automattic\WooCommerce\Proxies\LegacyProxy;
+use WP_REST_Request;
+use WP_REST_Response;
+use WP_REST_Server;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * Handles the removal of the WooCommerce MCP endpoint (woocommerce/mcp) and its REST-derived abilities.
+ *
+ * WooCommerce abilities remain registered through the WordPress Abilities API, and the MCP Adapter
+ * plugin exposes them to MCP clients.
+ *
+ * @since 11.3.0
+ */
+class McpEndpointRemoval {
+
+ /**
+ * WooCommerce version that removed the WooCommerce MCP endpoint.
+ *
+ * @var string
+ */
+ public const REMOVED_IN = '11.3.0';
+
+ /**
+ * Documentation for using WooCommerce abilities with MCP.
+ *
+ * @var string
+ */
+ public const DOCS_URL = 'https://developer.woocommerce.com/docs/features/mcp/';
+
+ /**
+ * Namespace of the removed WooCommerce MCP endpoint.
+ *
+ * @var string
+ */
+ private const ENDPOINT_NAMESPACE = 'woocommerce';
+
+ /**
+ * Route of the removed WooCommerce MCP endpoint.
+ *
+ * @var string
+ */
+ private const ENDPOINT_ROUTE = '/mcp';
+
+ /**
+ * Option that stored whether the removed WooCommerce MCP feature was enabled.
+ *
+ * @var string
+ */
+ private const FEATURE_OPTION = 'woocommerce_feature_mcp_integration_enabled';
+
+ /**
+ * Name of the admin notice about the removal, used for dismissal.
+ *
+ * @var string
+ */
+ public const REMOVED_NOTICE = 'mcp_endpoint_removed';
+
+ /**
+ * Plugin file of the MCP Adapter plugin from WordPress.org.
+ *
+ * @var string
+ */
+ private const ADAPTER_PLUGIN_FILE = 'mcp-adapter/mcp-adapter.php';
+
+ /**
+ * Query argument added to the notice's activation link, so activations started from the notice can be recognized.
+ *
+ * @var string
+ */
+ private const NOTICE_ACTIVATION_ARG = 'wc-mcp-removal-notice';
+
+ /**
+ * Constructor.
+ */
+ public function __construct() {
+ add_action( 'rest_api_init', array( $this, 'handle_rest_api_init' ) );
+ add_action( 'admin_notices', array( $this, 'handle_admin_notices' ) );
+ add_action( 'admin_enqueue_scripts', array( $this, 'handle_admin_enqueue_scripts' ) );
+ add_action( 'activated_plugin', array( $this, 'handle_activated_plugin' ) );
+ add_action( 'woocommerce_hide_' . self::REMOVED_NOTICE . '_notice', array( $this, 'handle_woocommerce_hide_mcp_endpoint_removed_notice' ) );
+ }
+
+ /**
+ * Handle the activated_plugin hook.
+ *
+ * When the MCP Adapter plugin is activated from the removal notice's link, the store has acted on the notice,
+ * so delete the old feature option that triggers it for every admin.
+ *
+ * @internal
+ *
+ * @param mixed $plugin Path to the activated plugin file, relative to the plugins directory.
+ */
+ public function handle_activated_plugin( $plugin ): void {
+ // Core verified the activation nonce before activating; the argument only identifies where the activation started.
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ if ( self::ADAPTER_PLUGIN_FILE === $plugin && isset( $_GET[ self::NOTICE_ACTIVATION_ARG ] ) ) {
+ delete_option( self::FEATURE_OPTION );
+ }
+ }
+
+ /**
+ * Handle the woocommerce_hide_mcp_endpoint_removed_notice hook.
+ *
+ * Dismissing the removal notice acts on it for the whole store, so delete the old feature option that triggers it.
+ *
+ * @internal
+ */
+ public function handle_woocommerce_hide_mcp_endpoint_removed_notice(): void {
+ delete_option( self::FEATURE_OPTION );
+ }
+
+ /**
+ * Handle the admin_enqueue_scripts hook.
+ *
+ * Loads the core scripts that open the MCP Adapter plugin details in a modal, only when the notice shows the install link.
+ *
+ * @internal
+ */
+ public function handle_admin_enqueue_scripts(): void {
+ if ( ! $this->should_show_removed_notice() || 'install' !== $this->get_adapter_link_type() ) {
+ return;
+ }
+
+ add_thickbox();
+ wp_enqueue_script( 'plugin-install' );
+ }
+
+ /**
+ * Handle the admin_notices hook.
+ *
+ * Tells stores that had the WooCommerce MCP feature enabled what was removed and how to keep using WooCommerce with MCP.
+ *
+ * @internal
+ */
+ public function handle_admin_notices(): void {
+ if ( ! $this->should_show_removed_notice() ) {
+ return;
+ }
+
+ $message = sprintf(
+ '<strong>%1$s</strong> %2$s <a href="%3$s" target="_blank" rel="noopener noreferrer">%4$s</a>',
+ esc_html__( 'WooCommerce MCP has been removed.', 'woocommerce' ),
+ esc_html(
+ sprintf(
+ /* translators: %s: WooCommerce version. */
+ __( 'As of WooCommerce %s, the WooCommerce MCP endpoint (/wp-json/woocommerce/mcp), its REST API key authentication, and its REST-based product and order tools are no longer available. WooCommerce abilities are still available through the WordPress Abilities API. Use the MCP Adapter plugin to connect MCP clients to them.', 'woocommerce' ),
+ self::REMOVED_IN
+ )
+ ),
+ esc_url( self::DOCS_URL ),
+ esc_html__( 'Learn more', 'woocommerce' )
+ );
+
+ $link_type = $this->get_adapter_link_type();
+ if ( 'install' === $link_type ) {
+ $message .= sprintf(
+ ' | <a href="%1$s" class="thickbox open-plugin-details-modal" data-title="%2$s">%3$s</a>',
+ esc_url( network_admin_url( 'plugin-install.php?tab=plugin-information&plugin=mcp-adapter&TB_iframe=true&width=600&height=550' ) ),
+ esc_attr__( 'MCP Adapter', 'woocommerce' ),
+ esc_html__( 'Install MCP Adapter', 'woocommerce' )
+ );
+ } elseif ( 'activate' === $link_type ) {
+ $message .= sprintf(
+ ' | <a href="%1$s">%2$s</a>',
+ esc_url( wp_nonce_url( admin_url( 'plugins.php?action=activate&plugin=' . rawurlencode( self::ADAPTER_PLUGIN_FILE ) . '&' . self::NOTICE_ACTIVATION_ARG . '=1' ), 'activate-plugin_' . self::ADAPTER_PLUGIN_FILE ) ),
+ esc_html__( 'Activate MCP Adapter', 'woocommerce' )
+ );
+ }
+
+ $dismiss_url = wp_nonce_url(
+ add_query_arg( 'wc-hide-notice', self::REMOVED_NOTICE ),
+ 'woocommerce_hide_notices_nonce',
+ '_wc_notice_nonce'
+ );
+
+ echo wp_kses_post(
+ '<div class="notice notice-warning is-dismissible">'
+ . '<a class="woocommerce-message-close notice-dismiss" style="text-decoration: none;" href="' . esc_url( $dismiss_url ) . '" aria-label="' . esc_attr__( 'Dismiss this notice', 'woocommerce' ) . '"></a>'
+ . '<p>' . $message . '</p>'
+ . '</div>'
+ );
+ }
+
+ /**
+ * Check whether the removal notice should show to the current user.
+ *
+ * @return bool
+ */
+ private function should_show_removed_notice(): bool {
+ return 'yes' === get_option( self::FEATURE_OPTION )
+ && current_user_can( 'manage_woocommerce' )
+ && ! \WC_Admin_Notices::user_has_dismissed_notice( self::REMOVED_NOTICE );
+ }
+
+ /**
+ * Decide which MCP Adapter link the removal notice shows to the current user.
+ *
+ * No link is shown when an MCP adapter is already loaded, from the MCP Adapter plugin or any other plugin.
+ *
+ * @return string 'install', 'activate', or '' for no link.
+ */
+ private function get_adapter_link_type(): string {
+ if ( self::is_mcp_adapter_available() ) {
+ return '';
+ }
+
+ $proxy = wc_get_container()->get( LegacyProxy::class );
+ if ( $proxy->call_function( 'file_exists', WP_PLUGIN_DIR . '/' . self::ADAPTER_PLUGIN_FILE ) ) {
+ return current_user_can( 'activate_plugins' ) ? 'activate' : '';
+ }
+
+ return current_user_can( 'install_plugins' ) ? 'install' : '';
+ }
+
+ /**
+ * Check whether an MCP adapter is loaded, from the MCP Adapter plugin or any other plugin.
+ *
+ * @return bool
+ */
+ private static function is_mcp_adapter_available(): bool {
+ return (bool) wc_get_container()->get( LegacyProxy::class )->call_function( 'class_exists', 'WP\MCP\Core\McpAdapter' );
+ }
+
+ /**
+ * Handle the rest_api_init hook.
+ *
+ * Registers the removed WooCommerce MCP endpoint so MCP clients that still use it get an explanation instead of a 404.
+ *
+ * @internal
+ */
+ public function handle_rest_api_init(): void {
+ register_rest_route(
+ self::ENDPOINT_NAMESPACE,
+ self::ENDPOINT_ROUTE,
+ array(
+ 'methods' => WP_REST_Server::ALLMETHODS,
+ 'callback' => array( $this, 'get_endpoint_removed_response' ),
+ 'permission_callback' => '__return_true',
+ )
+ );
+ }
+
+ /**
+ * Respond to a request to the removed WooCommerce MCP endpoint.
+ *
+ * The response is a JSON-RPC error, which MCP clients surface to the user or agent, sent with HTTP status 410 Gone.
+ *
+ * @internal
+ *
+ * @param WP_REST_Request $request The REST request.
+ * @phpstan-param WP_REST_Request<array<string, mixed>> $request
+ * @return WP_REST_Response
+ */
+ public function get_endpoint_removed_response( WP_REST_Request $request ): WP_REST_Response {
+ $params = $request->get_json_params();
+ $request_id = is_array( $params ) && isset( $params['id'] ) && ( is_string( $params['id'] ) || is_int( $params['id'] ) ) ? $params['id'] : null;
+ $adapter = rest_url( 'mcp/mcp-adapter-default-server' );
+
+ return new WP_REST_Response(
+ array(
+ 'jsonrpc' => '2.0',
+ 'id' => $request_id,
+ 'error' => array(
+ 'code' => -32000,
+ 'message' => sprintf(
+ /* translators: 1: WooCommerce version, 2: MCP Adapter endpoint URL, 3: Documentation URL. */
+ __( 'The WooCommerce MCP endpoint (woocommerce/mcp) was removed in WooCommerce %1$s. To keep using WooCommerce with MCP, install and activate the MCP Adapter plugin (https://wordpress.org/plugins/mcp-adapter/) on this site, then connect your MCP client to %2$s using a WordPress application password. Documentation: %3$s', 'woocommerce' ),
+ self::REMOVED_IN,
+ $adapter,
+ self::DOCS_URL
+ ),
+ 'data' => array(
+ 'replacement_endpoint' => $adapter,
+ 'documentation_url' => self::DOCS_URL,
+ ),
+ ),
+ ),
+ 410
+ );
+ }
+
+ /**
+ * Report a call to code that was removed along with the WooCommerce MCP endpoint.
+ *
+ * @param string $function_name The removed function or method that was called.
+ */
+ public static function doing_it_wrong( string $function_name ): void {
+ wc_doing_it_wrong(
+ $function_name,
+ sprintf(
+ /* translators: 1: Function or method name, 2: WooCommerce version, 3: Documentation URL. */
+ __( '%1$s was removed in WooCommerce %2$s along with the WooCommerce MCP endpoint and its REST-based abilities. WooCommerce abilities remain available through the WordPress Abilities API, and the MCP Adapter plugin exposes them to MCP clients. See %3$s.', 'woocommerce' ),
+ $function_name,
+ self::REMOVED_IN,
+ self::DOCS_URL
+ ),
+ self::REMOVED_IN
+ );
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/MCP/Transport/WooCommerceRestTransport.php b/plugins/woocommerce/src/Internal/MCP/Transport/WooCommerceRestTransport.php
index 39942de7252..162d8255718 100644
--- a/plugins/woocommerce/src/Internal/MCP/Transport/WooCommerceRestTransport.php
+++ b/plugins/woocommerce/src/Internal/MCP/Transport/WooCommerceRestTransport.php
@@ -1,206 +1,106 @@
<?php
/**
- * WooCommerce MCP REST Transport with API validation.
+ * WooCommerce MCP REST Transport class file.
*/
declare( strict_types=1 );
namespace Automattic\WooCommerce\Internal\MCP\Transport;
-use WP\MCP\Transport\HttpTransport;
-use WP\MCP\Transport\Infrastructure\McpTransportContext;
-use WP_REST_Request;
+use Automattic\WooCommerce\Internal\MCP\McpEndpointRemoval;
use WP_Error;
defined( 'ABSPATH' ) || exit;
/**
- * WooCommerce MCP REST Transport class.
+ * Formerly authenticated requests to the WooCommerce MCP endpoint with WooCommerce REST API keys.
*
- * Extends the base HttpTransport with standalone WooCommerce REST API key authentication.
- * Uses X-MCP-API-Key header with consumer_key:consumer_secret format.
+ * Kept so existing callers don't fatal. It no longer extends the MCP adapter's HttpTransport, and the
+ * methods only report incorrect usage and deny access.
+ *
+ * @deprecated 11.3.0 The WooCommerce MCP endpoint was removed. Use the MCP Adapter plugin to access WooCommerce abilities over MCP.
*/
-class WooCommerceRestTransport extends HttpTransport {
-
- /**
- * Current MCP user's API key permissions.
- *
- * @var string|null
- */
- private static $current_mcp_permissions = null;
+class WooCommerceRestTransport {
/**
* Constructor.
*
- * @param McpTransportContext $context The transport context.
+ * @deprecated 11.3.0
+ *
+ * @param mixed $context The transport context. Ignored.
*/
- public function __construct( McpTransportContext $context ) {
- parent::__construct( $context );
-
- // This filter is documented in the check_ability_permission method.
- add_filter( 'woocommerce_check_rest_ability_permissions_for_method', array( $this, 'check_ability_permission' ), 10, 3 );
+ public function __construct( $context = null ) {
+ unset( $context ); // Kept for backward compatibility.
+ McpEndpointRemoval::doing_it_wrong( __METHOD__ );
}
/**
- * Validate request using WooCommerce REST API authentication.
+ * Formerly checked access to the WooCommerce MCP endpoint.
+ *
+ * @deprecated 11.3.0
*
- * @param WP_REST_Request|null $request The REST request object.
- * @return bool|\WP_Error True if allowed, WP_Error if not.
+ * @param \WP_REST_Request<array<string, mixed>>|null $request The REST request object. Ignored.
+ * @return WP_Error Always an error, since the endpoint was removed.
*/
public function check_permission( $request = null ) {
- return $this->validate_request( $request );
+ unset( $request ); // Kept for backward compatibility.
+ McpEndpointRemoval::doing_it_wrong( __METHOD__ );
+ return self::get_removed_error();
}
/**
- * Validate the MCP request using standalone authentication.
+ * Formerly validated a request to the WooCommerce MCP endpoint.
*
- * @param \WP_REST_Request $request The REST request object.
- * @return bool|\WP_Error True if allowed, WP_Error if not.
+ * @deprecated 11.3.0
+ *
+ * @param \WP_REST_Request $request The REST request object. Ignored.
+ * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
+ * @return WP_Error Always an error, since the endpoint was removed.
*/
public function validate_request( \WP_REST_Request $request ) {
- // Require TLS by default; allow explicit opt-in for non-SSL (e.g., local dev).
- /**
- * Filter to allow insecure transport for MCP requests.
- *
- * @since 10.3.0
- * @param bool $allowed Whether to allow insecure transport.
- * @param \WP_REST_Request $request The REST request object.
- */
- if ( ! is_ssl() && ! apply_filters( 'woocommerce_mcp_allow_insecure_transport', false, $request ) ) {
- return new \WP_Error(
- 'insecure_transport',
- __( 'HTTPS is required for MCP requests.', 'woocommerce' ),
- array( 'status' => 403 )
- );
- }
-
- // Get X-MCP-API-Key header.
- $api_key = $request->get_header( 'X-MCP-API-Key' );
-
- if ( empty( $api_key ) ) {
- return new \WP_Error(
- 'missing_api_key',
- __( 'X-MCP-API-Key header required. Format: consumer_key:consumer_secret', 'woocommerce' ),
- array( 'status' => 401 )
- );
- }
-
- if ( strpos( $api_key, ':' ) === false ) {
- return new \WP_Error(
- 'invalid_api_key',
- __( 'X-MCP-API-Key must be in format consumer_key:consumer_secret', 'woocommerce' ),
- array( 'status' => 401 )
- );
- }
-
- list( $consumer_key, $consumer_secret ) = explode( ':', $api_key, 2 );
-
- // Use our standalone authentication method.
- $result = $this->authenticate( $consumer_key, $consumer_secret );
-
- if ( is_wp_error( $result ) ) {
- return $result;
- }
-
- return true;
+ unset( $request ); // Kept for backward compatibility.
+ McpEndpointRemoval::doing_it_wrong( __METHOD__ );
+ return self::get_removed_error();
}
/**
- * Authenticate user using consumer key and secret.
+ * Formerly returned the API key permissions of the current WooCommerce MCP request.
*
- * @param string $consumer_key Consumer key.
- * @param string $consumer_secret Consumer secret.
- * @return int|\WP_Error User ID on success, WP_Error on failure.
+ * @deprecated 11.3.0
+ *
+ * @return null Always null, since there is no WooCommerce MCP request.
*/
- private function authenticate( $consumer_key, $consumer_secret ) {
- global $wpdb;
-
- // Hash the consumer key as WooCommerce does.
- $hashed_consumer_key = wc_api_hash( trim( (string) $consumer_key ) );
-
- // Query the WooCommerce API keys table directly.
- $user_data = $wpdb->get_row(
- $wpdb->prepare(
- "SELECT key_id, user_id, permissions, consumer_key, consumer_secret, nonces
- FROM {$wpdb->prefix}woocommerce_api_keys
- WHERE consumer_key = %s",
- $hashed_consumer_key
- )
- );
-
- // Check if user data was found.
- if ( empty( $user_data ) ) {
- return new \WP_Error(
- 'authentication_failed',
- __( 'Authentication failed.', 'woocommerce' ),
- array( 'status' => 401 )
- );
- }
-
- // Validate consumer secret using hash_equals for timing attack protection.
- if ( ! hash_equals( $user_data->consumer_secret, trim( (string) $consumer_secret ) ) ) {
- return new \WP_Error(
- 'authentication_failed',
- __( 'Authentication failed.', 'woocommerce' ),
- array( 'status' => 401 )
- );
- }
-
- // Store permissions for tool-level checking.
- self::$current_mcp_permissions = $user_data->permissions;
-
- // Ensure the user exists before switching context.
- $user = get_user_by( 'id', (int) $user_data->user_id );
- if ( ! $user ) {
- return new \WP_Error(
- 'mcp_user_not_found',
- __( 'The user associated with this API key no longer exists.', 'woocommerce' ),
- array( 'status' => 401 )
- );
- }
- wp_set_current_user( $user->ID );
-
- return $user->ID;
+ public static function get_current_user_permissions(): ?string {
+ McpEndpointRemoval::doing_it_wrong( __METHOD__ );
+ return null;
}
/**
- * Get the current MCP user's API key permissions.
+ * Formerly checked REST-derived ability permissions against the API key permissions.
*
- * @return string|null The permissions (read, write, read_write) or null if no MCP context.
+ * @deprecated 11.3.0
+ *
+ * @param bool $allowed Whether the operation is allowed.
+ * @param string $method HTTP method. Ignored.
+ * @param object $controller REST controller instance. Ignored.
+ * @return bool The unchanged $allowed value.
*/
- public static function get_current_user_permissions(): ?string {
- return self::$current_mcp_permissions;
+ public function check_ability_permission( $allowed, $method, $controller ) {
+ unset( $method, $controller ); // Kept for backward compatibility.
+ McpEndpointRemoval::doing_it_wrong( __METHOD__ );
+ return $allowed;
}
/**
- * Check REST ability permissions for HTTP method.
+ * Get the error returned in place of the removed authentication.
*
- * @param bool $allowed Whether the operation is allowed. Default false.
- * @param string $method HTTP method (GET, POST, PUT, DELETE).
- * @param object $controller REST controller instance.
- * @return bool Whether permission is granted.
+ * @return WP_Error
*/
- public function check_ability_permission( $allowed, $method, $controller ) {
- // Only check permissions if we have MCP context.
- $permissions = self::get_current_user_permissions();
- if ( null === $permissions ) {
- return $allowed;
- }
-
- // Check permissions based on method.
- switch ( $method ) {
- case 'HEAD':
- case 'GET':
- return ( 'read' === $permissions || 'read_write' === $permissions );
- case 'POST':
- case 'PUT':
- case 'PATCH':
- case 'DELETE':
- return ( 'write' === $permissions || 'read_write' === $permissions );
- case 'OPTIONS':
- return true;
- default:
- return false;
- }
+ private static function get_removed_error(): WP_Error {
+ return new WP_Error(
+ 'woocommerce_mcp_removed',
+ __( 'The WooCommerce MCP endpoint was removed.', 'woocommerce' ),
+ array( 'status' => 410 )
+ );
}
}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
index f03d335c700..56593e609a3 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
@@ -112,13 +112,6 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
$this->original_action_counts[ $action ] = $wp_actions[ $action ] ?? null;
}
- if ( ! function_exists( 'wp_register_ability' ) ) {
- $abilities_bootstrap = WC_ABSPATH . 'vendor/wordpress/abilities-api/includes/bootstrap.php';
- if ( file_exists( $abilities_bootstrap ) ) {
- require_once $abilities_bootstrap;
- }
- }
-
// WordPress 6.9+ requires init to have fired before the Abilities API registry can be initialized.
$wp_actions['init'] = max( 1, (int) ( $wp_actions['init'] ?? 0 ) ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/REST/RestAbilityFactoryTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/REST/RestAbilityFactoryTest.php
deleted file mode 100644
index 1552bcbe23e..00000000000
--- a/plugins/woocommerce/tests/php/src/Internal/Abilities/REST/RestAbilityFactoryTest.php
+++ /dev/null
@@ -1,1340 +0,0 @@
-<?php
-declare( strict_types = 1 );
-
-namespace Automattic\WooCommerce\Tests\Internal\Abilities\REST;
-
-use Automattic\WooCommerce\Internal\Abilities\REST\RestAbilityFactory;
-use WC_Unit_Test_Case;
-
-/**
- * Tests for the RestAbilityFactory class.
- *
- * Focuses on schema sanitization logic in sanitize_args_to_schema().
- */
-class RestAbilityFactoryTest extends WC_Unit_Test_Case {
-
- /**
- * Valid JSON Schema types per the spec.
- */
- private const VALID_JSON_SCHEMA_TYPES = array( 'string', 'number', 'integer', 'boolean', 'object', 'array', 'null' );
-
- /**
- * Ability IDs registered by these tests.
- *
- * @var array
- */
- private $registered_ability_ids = array();
-
- /**
- * Ability category IDs registered by these tests.
- *
- * @var array
- */
- private $registered_ability_category_ids = array();
-
- /**
- * Original value of $wp_actions['wp_abilities_api_init'] to restore in tearDown.
- *
- * @var int|null
- */
- private $original_wp_abilities_api_init_action_count;
-
- /**
- * Original value of $wp_actions['wp_abilities_api_categories_init'] to restore in tearDown.
- *
- * @var int|null
- */
- private $original_wp_abilities_api_categories_init_action_count;
-
- /**
- * Set up before each test.
- */
- public function setUp(): void {
- global $wp_actions;
-
- parent::setUp();
-
- $this->original_wp_abilities_api_init_action_count = $wp_actions['wp_abilities_api_init'] ?? null;
- $this->original_wp_abilities_api_categories_init_action_count = $wp_actions['wp_abilities_api_categories_init'] ?? null;
-
- if ( ! function_exists( 'wp_register_ability' ) ) {
- $abilities_bootstrap = WP_PLUGIN_DIR . '/woocommerce/vendor/wordpress/abilities-api/includes/bootstrap.php';
- if ( file_exists( $abilities_bootstrap ) ) {
- require_once $abilities_bootstrap;
- }
- }
- }
-
- /**
- * Clean up after each test.
- */
- public function tearDown(): void {
- global $wp_actions;
-
- foreach ( $this->registered_ability_ids as $ability_id ) {
- if ( function_exists( 'wp_unregister_ability' ) ) {
- wp_unregister_ability( $ability_id );
- }
- }
- $this->registered_ability_ids = array();
-
- foreach ( $this->registered_ability_category_ids as $category_id ) {
- if ( function_exists( 'wp_unregister_ability_category' ) ) {
- wp_unregister_ability_category( $category_id );
- }
- }
- $this->registered_ability_category_ids = array();
-
- if ( null !== $this->original_wp_abilities_api_init_action_count ) {
- $wp_actions['wp_abilities_api_init'] = $this->original_wp_abilities_api_init_action_count; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
- } elseif ( isset( $wp_actions['wp_abilities_api_init'] ) ) {
- unset( $wp_actions['wp_abilities_api_init'] ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
- }
-
- if ( null !== $this->original_wp_abilities_api_categories_init_action_count ) {
- $wp_actions['wp_abilities_api_categories_init'] = $this->original_wp_abilities_api_categories_init_action_count; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
- } elseif ( isset( $wp_actions['wp_abilities_api_categories_init'] ) ) {
- unset( $wp_actions['wp_abilities_api_categories_init'] ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
- }
-
- parent::tearDown();
- }
-
- /**
- * Helper to invoke the private sanitize_args_to_schema method.
- *
- * @param array $args WordPress REST API arguments array.
- * @return array Sanitized JSON Schema.
- */
- private function invoke_sanitize_args_to_schema( array $args ): array {
- $reflection = new \ReflectionClass( RestAbilityFactory::class );
- $method = $reflection->getMethod( 'sanitize_args_to_schema' );
- $method->setAccessible( true );
-
- return $method->invoke( null, $args );
- }
-
- /**
- * Helper to invoke the private get_output_schema method.
- *
- * @param object $controller REST controller instance.
- * @param string $operation Operation type.
- * @return array Output schema.
- */
- private function invoke_get_output_schema( $controller, string $operation ): array {
- $reflection = new \ReflectionClass( RestAbilityFactory::class );
- $method = $reflection->getMethod( 'get_output_schema' );
- $method->setAccessible( true );
-
- return $method->invoke( null, $controller, $operation );
- }
-
- /**
- * Register the test ability category if the suite has not already registered it.
- *
- * @param string $category_id Ability category ID.
- */
- private function ensure_test_ability_category( string $category_id ): void {
- if ( ! function_exists( 'wp_register_ability_category' ) || ! function_exists( 'wp_has_ability_category' ) ) {
- return;
- }
-
- if ( wp_has_ability_category( $category_id ) ) {
- return;
- }
-
- $category = null;
- $callback = null;
- $callback = function () use ( &$category, $category_id, &$callback ) {
- remove_action( 'wp_abilities_api_categories_init', $callback );
-
- if ( wp_has_ability_category( $category_id ) ) {
- return;
- }
-
- $category = wp_register_ability_category(
- $category_id,
- array(
- 'label' => 'WooCommerce REST API',
- 'description' => 'REST API operations for WooCommerce resources.',
- )
- );
- };
-
- add_action( 'wp_abilities_api_categories_init', $callback );
- do_action( 'wp_abilities_api_categories_init' );
- remove_action( 'wp_abilities_api_categories_init', $callback );
-
- if ( null !== $category ) {
- $this->assertNotWPError( $category, 'Test ability category should register successfully.' );
- $this->assertNotNull( $category, 'Test ability category should register successfully.' );
- $this->registered_ability_category_ids[] = $category_id;
- }
-
- $this->assertTrue( wp_has_ability_category( $category_id ), 'Test ability category should be available.' );
- }
-
- /**
- * Recursively collect all 'type' values from a schema.
- *
- * @param array $schema JSON Schema array.
- * @return array All type values found.
- */
- private function collect_all_types( array $schema ): array {
- $types = array();
-
- if ( isset( $schema['type'] ) ) {
- if ( is_array( $schema['type'] ) ) {
- $types = array_merge( $types, $schema['type'] );
- } else {
- $types[] = $schema['type'];
- }
- }
-
- if ( isset( $schema['properties'] ) && is_array( $schema['properties'] ) ) {
- foreach ( $schema['properties'] as $property ) {
- if ( is_array( $property ) ) {
- $types = array_merge( $types, $this->collect_all_types( $property ) );
- }
- }
- }
-
- if ( isset( $schema['items'] ) && is_array( $schema['items'] ) ) {
- $types = array_merge( $types, $this->collect_all_types( $schema['items'] ) );
- }
-
- return $types;
- }
-
- /**
- * Create a mock controller with a given item schema.
- *
- * @param array $item_schema The schema to return from get_item_schema.
- * @return object Mock controller.
- */
- private function create_mock_controller_with_item_schema( array $item_schema ): object {
- return new class( $item_schema ) {
- /**
- * The schema.
- *
- * @var array
- */
- private array $schema;
-
- /**
- * Constructor.
- *
- * @param array $schema The schema.
- */
- public function __construct( array $schema ) {
- $this->schema = $schema;
- }
-
- /**
- * Get item schema.
- *
- * @return array
- */
- public function get_item_schema(): array {
- return $this->schema;
- }
- };
- }
-
- /**
- * @testdox Should mark REST-derived abilities for the deprecated WooCommerce MCP endpoint.
- */
- public function test_register_controller_abilities_marks_rest_abilities_for_deprecated_mcp(): void {
- $this->assertTrue( function_exists( 'wp_register_ability' ), 'Abilities API should be available.' );
- $this->assertTrue( class_exists( \WC_REST_Products_Controller::class ), 'Products REST controller should be available.' );
- $this->ensure_test_ability_category( 'woocommerce-rest' );
-
- $ability_id = 'woocommerce/rest-factory-metadata-test';
- $config = array(
- 'controller' => \WC_REST_Products_Controller::class,
- 'route' => '/wc/v3/products',
- 'abilities' => array(
- array(
- 'id' => $ability_id,
- 'operation' => 'list',
- 'label' => 'List REST factory test products',
- 'description' => 'Retrieve REST factory test products.',
- ),
- ),
- );
-
- $callback = null;
- $callback = static function () use ( $config, &$callback ) {
- remove_action( 'wp_abilities_api_init', $callback );
-
- RestAbilityFactory::register_controller_abilities( $config );
- };
-
- add_action( 'wp_abilities_api_init', $callback );
- do_action( 'wp_abilities_api_init' );
- remove_action( 'wp_abilities_api_init', $callback );
-
- $ability = wp_get_ability( $ability_id );
-
- $this->assertNotNull( $ability, 'REST-derived test ability should register successfully.' );
- $this->registered_ability_ids[] = $ability_id;
- $this->assertTrue( $ability->get_meta_item( 'show_in_rest', false ), 'REST-derived abilities should remain exposed through the Abilities REST API.' );
- $this->assertTrue( $ability->get_meta_item( RestAbilityFactory::EXPOSE_IN_DEPRECATED_MCP_META_KEY, false ), 'REST-derived abilities should opt in to the deprecated WooCommerce MCP endpoint.' );
- }
-
- // ── Bug 1: date-time type conversion (issue #62764) ──
-
- /**
- * @testdox Should convert date-time type to string with date-time format.
- */
- public function test_converts_date_time_type_to_string_with_format(): void {
- $args = array(
- 'date_created' => array(
- 'type' => 'date-time',
- 'description' => 'The date the resource was created.',
- ),
- );
-
- $schema = $this->invoke_sanitize_args_to_schema( $args );
-
- $this->assertSame( 'string', $schema['properties']['date_created']['type'], 'date-time should be converted to string type' );
- $this->assertSame( 'date-time', $schema['properties']['date_created']['format'], 'date-time format should be set' );
- }
-
- /**
- * @testdox Should preserve explicit format when converting date-time type.
- */
- public function test_date_time_conversion_preserves_explicit_format(): void {
- $args = array(
- 'date_field' => array(
- 'type' => 'date-time',
- 'format' => 'date-time',
- ),
- );
-
- $schema = $this->invoke_sanitize_args_to_schema( $args );
-
- $this->assertSame( 'string', $schema['properties']['date_field']['type'] );
- $this->assertSame( 'date-time', $schema['properties']['date_field']['format'] );
- }
-
- // ── Bug 2: duplicate enum values (issue #62034) ──
-
- /**
- * @testdox Should deduplicate enum values.
- */
- public function test_deduplicates_enum_values(): void {
- $args = array(
- 'orderby' => array(
- 'type' => 'string',
- 'enum' => array( 'date', 'id', 'title', 'price', 'popularity', 'rating', 'price', 'popularity', 'rating' ),
- ),
- );
-
- $schema = $this->invoke_sanitize_args_to_schema( $args );
-
- $enum = $schema['properties']['orderby']['enum'];
- $this->assertSame( array_values( array_unique( $enum ) ), $enum, 'Enum should not contain duplicate values' );
- $this->assertCount( 6, $enum );
- }
-
- /**
- * @testdox Should reindex enum values after deduplication.
- */
- public function test_enum_values_are_reindexed(): void {
- $args = array(
- 'status' => array(
- 'type' => 'string',
- 'enum' => array( 'draft', 'published', 'draft' ),
- ),
- );
-
- $schema = $this->invoke_sanitize_args_to_schema( $args );
-
- $this->assertSame( array( 'draft', 'published' ), $schema['properties']['status']['enum'] );
- }
-
- // ── Gap 1: invalid types like 'mixed' and 'action' ──
-
- /**
- * @testdox Should handle type mixed by removing the type key.
- */
- public function test_handles_mixed_type(): void {
- $args = array(
- 'value' => array(
- 'type' => 'mixed',
- 'description' => 'Meta value.',
- ),
- );
-
- $schema = $this->invoke_sanitize_args_to_schema( $args );
-
- $this->assertArrayNotHasKey( 'type', $schema['properties']['value'], 'mixed type should be removed' );
- $this->assertSame( 'Meta value.', $schema['properties']['value']['description'] );
- }
-
- /**
- * @testdox Should handle type action by converting to object.
- */
- public function test_handles_action_type(): void {
- $args = array(
- 'line_items' => array(
- 'type' => 'action',
- 'description' => 'Line items.',
- ),
- );
-
- $schema = $this->invoke_sanitize_args_to_schema( $args );
-
- $this->assertSame( 'object', $schema['properties']['line_items']['type'], 'action type should be converted to object' );
- }
-
- /**
- * @testdox Should remove any unrecognized type value.
- */
- public function test_handles_unrecognized_type(): void {
- $args = array(
- 'field' => array(
- 'type' => 'foobar',
- 'description' => 'Unknown type field.',
- ),
- );
-
- $schema = $this->invoke_sanitize_args_to_schema( $args );
-
- $this->assertArrayNotHasKey( 'type', $schema['properties']['field'], 'Unrecognized type should be removed' );
- }
-
- /**
- * @testdox Should preserve all valid JSON Schema types.
- */
- public function test_preserves_valid_types(): void {
- $args = array();
- foreach ( self::VALID_JSON_SCHEMA_TYPES as $type ) {
- $args[ $type . '_field' ] = array( 'type' => $type );
- }
-
- $schema = $this->invoke_sanitize_args_to_schema( $args );
-
- foreach ( self::VALID_JSON_SCHEMA_TYPES as $type ) {
- $this->assertSame( $type, $schema['properties'][ $type . '_field' ]['type'], "Valid type '$type' should be preserved" );
- }
- }
-
- /**
- * @testdox Should collect required fields correctly.
- */
- public function test_collects_required_fields(): void {
- $args = array(
- 'name' => array(
- 'type' => 'string',
- 'required' => true,
- ),
- 'price' => array(
- 'type' => 'string',
- 'required' => true,
- ),
- 'sku' => array(
- 'type' => 'string',
- 'required' => false,
- ),
- );
-
- $schema = $this->invoke_sanitize_args_to_schema( $args );
-
- $this->assertArrayHasKey( 'required', $schema );
- $this->assertContains( 'name', $schema['required'] );
- $this->assertContains( 'price', $schema['required'] );
- $this->assertNotContains( 'sku', $schema['required'] );
- }
-
- // ── Gap 3: recursive sanitization of nested properties/items ──
-
- /**
- * @testdox Should recursively sanitize nested properties with invalid types.
- */
- public function test_sanitizes_nested_properties(): void {
- $args = array(
- 'meta_data' => array(
- 'type' => 'array',
- 'items' => array(
- 'type' => 'object',
- 'properties' => array(
- 'key' => array( 'type' => 'string' ),
- 'value' => array( 'type' => 'mixed' ),
- ),
- ),
- ),
- );
-
- $schema = $this->invoke_sanitize_args_to_schema( $args );
-
- $all_types = $this->collect_all_types( $schema );
- $this->assertNotContains( 'mixed', $all_types, 'Nested mixed type should be sanitized' );
- }
-
- /**
- * @testdox Should recursively sanitize date-time in nested items.
- */
- public function test_sanitizes_nested_date_time(): void {
- $args = array(
- 'dates' => array(
- 'type' => 'array',
- 'items' => array(
- 'type' => 'object',
- 'properties' => array(
- 'created_at' => array( 'type' => 'date-time' ),
- 'updated_at' => array( 'type' => 'date-time' ),
- ),
- ),
- ),
- );
-
- $schema = $this->invoke_sanitize_args_to_schema( $args );
-
- $all_types = $this->collect_all_types( $schema );
- $this->assertNotContains( 'date-time', $all_types, 'Nested date-time type should be converted' );
-
- $created = $schema['properties']['dates']['items']['properties']['created_at'];
- $this->assertSame( 'string', $created['type'] );
- $this->assertSame( 'date-time', $created['format'] );
- }
-
- /**
- * @testdox Should recursively deduplicate nested enums.
- */
- public function test_sanitizes_nested_enums(): void {
- $args = array(
- 'filter' => array(
- 'type' => 'object',
- 'properties' => array(
- 'status' => array(
- 'type' => 'string',
- 'enum' => array( 'active', 'inactive', 'active' ),
- ),
- ),
- ),
- );
-
- $schema = $this->invoke_sanitize_args_to_schema( $args );
-
- $enum = $schema['properties']['filter']['properties']['status']['enum'];
- $this->assertCount( 2, $enum, 'Nested enum should be deduplicated' );
- $this->assertSame( array( 'active', 'inactive' ), $enum );
- }
-
- // ── Gap 2: output schema sanitization ──
-
- /**
- * @testdox Should sanitize output schema types for get operations.
- */
- public function test_sanitizes_output_schema_types(): void {
- $controller = $this->create_mock_controller_with_item_schema(
- array(
- 'type' => 'object',
- 'properties' => array(
- 'id' => array( 'type' => 'integer' ),
- 'date_created' => array( 'type' => 'date-time' ),
- 'meta_data' => array(
- 'type' => 'array',
- 'items' => array(
- 'type' => 'object',
- 'properties' => array(
- 'value' => array( 'type' => 'mixed' ),
- ),
- ),
- ),
- ),
- )
- );
-
- $schema = $this->invoke_get_output_schema( $controller, 'get' );
-
- $all_types = $this->collect_all_types( $schema );
- $this->assertNotContains( 'date-time', $all_types, 'Output schema should not contain date-time type' );
- $this->assertNotContains( 'mixed', $all_types, 'Output schema should not contain mixed type' );
- }
-
- /**
- * @testdox Should sanitize output schema types for list operations.
- */
- public function test_sanitizes_output_schema_for_list_operations(): void {
- $controller = $this->create_mock_controller_with_item_schema(
- array(
- 'type' => 'object',
- 'properties' => array(
- 'id' => array( 'type' => 'integer' ),
- 'date_created' => array( 'type' => 'date-time' ),
- ),
- )
- );
-
- $schema = $this->invoke_get_output_schema( $controller, 'list' );
-
- $all_types = $this->collect_all_types( $schema );
- $this->assertNotContains( 'date-time', $all_types, 'Output schema for list should not contain date-time type' );
- }
-
- // ── Array types (nullable fields) ──
-
- /**
- * @testdox Should normalize array type with valid types preserved.
- */
- public function test_normalizes_array_type_with_valid_types(): void {
- $args = array(
- 'name' => array(
- 'type' => array( 'string', 'null' ),
- ),
- );
-
- $schema = $this->invoke_sanitize_args_to_schema( $args );
-
- $this->assertSame( array( 'string', 'null' ), $schema['properties']['name']['type'], 'Valid array types should be preserved' );
- }
-
- /**
- * @testdox Should filter invalid types from array type and keep valid ones.
- */
- public function test_filters_invalid_types_from_array_type(): void {
- $args = array(
- 'value' => array(
- 'type' => array( 'mixed', 'string', 'null' ),
- ),
- );
-
- $schema = $this->invoke_sanitize_args_to_schema( $args );
-
- $this->assertSame( array( 'string', 'null' ), $schema['properties']['value']['type'], 'Invalid types should be filtered from array' );
- }
-
- /**
- * @testdox Should convert date-time in array type to string and set format.
- */
- public function test_converts_date_time_in_array_type(): void {
- $args = array(
- 'created' => array(
- 'type' => array( 'date-time', 'null' ),
- ),
- );
-
- $schema = $this->invoke_sanitize_args_to_schema( $args );
-
- $this->assertSame( array( 'string', 'null' ), $schema['properties']['created']['type'] );
- $this->assertSame( 'date-time', $schema['properties']['created']['format'] );
- }
-
- /**
- * @testdox Should convert action in array type to object.
- */
- public function test_converts_action_in_array_type(): void {
- $args = array(
- 'field' => array(
- 'type' => array( 'action', 'null' ),
- ),
- );
-
- $schema = $this->invoke_sanitize_args_to_schema( $args );
-
- $this->assertSame( array( 'object', 'null' ), $schema['properties']['field']['type'] );
- }
-
- /**
- * @testdox Should deduplicate array types after normalization.
- */
- public function test_deduplicates_array_types_after_normalization(): void {
- $args = array(
- 'field' => array(
- 'type' => array( 'date-time', 'string' ),
- ),
- );
-
- $schema = $this->invoke_sanitize_args_to_schema( $args );
-
- $this->assertSame( 'string', $schema['properties']['field']['type'], 'Should collapse to single type after dedup' );
- }
-
- /**
- * @testdox Should remove type key when all array types are invalid.
- */
- public function test_removes_type_when_all_array_types_invalid(): void {
- $args = array(
- 'field' => array(
- 'type' => array( 'mixed', 'foobar' ),
- 'description' => 'All bad types.',
- ),
- );
-
- $schema = $this->invoke_sanitize_args_to_schema( $args );
-
- $this->assertArrayNotHasKey( 'type', $schema['properties']['field'], 'Should remove type when all array types are invalid' );
- $this->assertSame( 'All bad types.', $schema['properties']['field']['description'] );
- }
-
- /**
- * @testdox Should handle non-string values in array type.
- */
- public function test_skips_non_string_values_in_array_type(): void {
- $args = array(
- 'field' => array(
- 'type' => array( 'string', 123, null, 'integer' ),
- ),
- );
-
- $schema = $this->invoke_sanitize_args_to_schema( $args );
-
- $this->assertSame( array( 'string', 'integer' ), $schema['properties']['field']['type'], 'Non-string values in type array should be skipped' );
- }
-
- // ── Robust enum deduplication ──
-
- /**
- * @testdox Should deduplicate enum with mixed scalar and complex values.
- */
- public function test_deduplicates_enum_with_mixed_value_types(): void {
- $args = array(
- 'value' => array(
- 'type' => 'string',
- 'enum' => array(
- 1,
- '1',
- null,
- null,
- array( 'a' => 1 ),
- array( 'a' => 1 ),
- array( 'a' => 2 ),
- ),
- ),
- );
-
- $schema = $this->invoke_sanitize_args_to_schema( $args );
-
- $enum = $schema['properties']['value']['enum'];
- $this->assertCount( 5, $enum, 'Should have 5 unique values: 1, "1", null, {a:1}, {a:2}' );
- $this->assertSame(
- array( 1, '1', null, array( 'a' => 1 ), array( 'a' => 2 ) ),
- $enum
- );
- }
-
- // ── Nested required boolean conversion ──
-
- /**
- * @testdox Should lift boolean required from nested properties to parent required array.
- */
- public function test_lifts_nested_boolean_required_to_parent_array(): void {
- $args = array(
- 'gift_cards' => array(
- 'type' => 'array',
- 'items' => array(
- 'type' => 'object',
- 'properties' => array(
- 'code' => array(
- 'type' => 'string',
- 'required' => true,
- ),
- 'amount' => array(
- 'type' => 'number',
- 'required' => false,
- ),
- ),
- ),
- ),
- );
-
- $schema = $this->invoke_sanitize_args_to_schema( $args );
-
- $items = $schema['properties']['gift_cards']['items'];
-
- $this->assertArrayNotHasKey( 'required', $items['properties']['code'], 'Boolean required should be removed from property' );
- $this->assertArrayNotHasKey( 'required', $items['properties']['amount'], 'Boolean required should be removed from property' );
-
- $this->assertArrayHasKey( 'required', $items, 'Parent object should have required array' );
- $this->assertContains( 'code', $items['required'], 'code should be in parent required array' );
- $this->assertNotContains( 'amount', $items['required'], 'amount should not be in parent required array' );
- }
-
- // ── Realistic scenario ──
-
- /**
- * @testdox Should sanitize realistic collection params with multiple issues.
- */
- public function test_sanitizes_realistic_collection_params(): void {
- $args = array(
- 'after' => array(
- 'type' => 'date-time',
- 'description' => 'Limit response to resources published after a given date.',
- ),
- 'before' => array(
- 'type' => 'date-time',
- 'description' => 'Limit response to resources published before a given date.',
- ),
- 'per_page' => array(
- 'type' => 'integer',
- 'default' => 10,
- 'minimum' => 1,
- 'maximum' => 100,
- ),
- 'orderby' => array(
- 'type' => 'string',
- 'default' => 'date',
- 'enum' => array( 'date', 'id', 'title', 'slug', 'price', 'popularity', 'rating', 'menu_order', 'price', 'popularity', 'rating' ),
- ),
- 'status' => array(
- 'type' => 'string',
- 'default' => 'any',
- 'enum' => array( 'any', 'draft', 'pending', 'private', 'publish' ),
- ),
- );
-
- $schema = $this->invoke_sanitize_args_to_schema( $args );
-
- $this->assertSame( 'string', $schema['properties']['after']['type'] );
- $this->assertSame( 'date-time', $schema['properties']['after']['format'] );
- $this->assertSame( 'string', $schema['properties']['before']['type'] );
- $this->assertSame( 'date-time', $schema['properties']['before']['format'] );
- $this->assertSame( 'integer', $schema['properties']['per_page']['type'] );
- $this->assertArrayNotHasKey( 'format', $schema['properties']['per_page'] );
-
- $orderby_enum = $schema['properties']['orderby']['enum'];
- $this->assertCount( count( array_unique( $orderby_enum ) ), $orderby_enum, 'orderby enum should have no duplicates' );
- $this->assertCount( 8, $orderby_enum );
- $this->assertCount( 5, $schema['properties']['status']['enum'] );
- }
-
- // ── Issue #64195: relax output schema for WC response quirks ──
-
- /**
- * @testdox Should strip format date-time from output schema properties.
- */
- public function test_output_schema_strips_format_date_time(): void {
- $controller = $this->create_mock_controller_with_item_schema(
- array(
- 'type' => 'object',
- 'properties' => array(
- 'date_created' => array( 'type' => 'date-time' ),
- ),
- )
- );
-
- $schema = $this->invoke_get_output_schema( $controller, 'get' );
-
- $this->assertArrayNotHasKey( 'format', $schema['properties']['date_created'], 'date-time format should be stripped from output schema (WC dates omit timezone)' );
- }
-
- /**
- * @testdox Should strip format uri from output schema properties.
- */
- public function test_output_schema_strips_format_uri(): void {
- $controller = $this->create_mock_controller_with_item_schema(
- array(
- 'type' => 'object',
- 'properties' => array(
- 'external_url' => array(
- 'type' => 'string',
- 'format' => 'uri',
- ),
- ),
- )
- );
-
- $schema = $this->invoke_get_output_schema( $controller, 'get' );
-
- $this->assertArrayNotHasKey( 'format', $schema['properties']['external_url'], 'uri format should be stripped from output schema (WC returns empty strings)' );
- }
-
- /**
- * @testdox Should preserve formats other than date-time and uri in output schema.
- */
- public function test_output_schema_preserves_other_formats(): void {
- $controller = $this->create_mock_controller_with_item_schema(
- array(
- 'type' => 'object',
- 'properties' => array(
- 'email' => array(
- 'type' => 'string',
- 'format' => 'email',
- ),
- 'hostname' => array(
- 'type' => 'string',
- 'format' => 'hostname',
- ),
- ),
- )
- );
-
- $schema = $this->invoke_get_output_schema( $controller, 'get' );
-
- $this->assertSame( 'email', $schema['properties']['email']['format'] );
- $this->assertSame( 'hostname', $schema['properties']['hostname']['format'] );
- }
-
- /**
- * Expected widened scalar union: {@see RestAbilityFactory::OUTPUT_SCALAR_UNION}.
- */
- private const SCALAR_UNION_WITH_NULL = array( 'string', 'integer', 'number', 'boolean', 'array', 'object', 'null' );
-
- /**
- * @testdox Should widen any single scalar type to the full scalar union plus null in output schema.
- */
- public function test_output_schema_widens_scalar_types_to_full_union(): void {
- $controller = $this->create_mock_controller_with_item_schema(
- array(
- 'type' => 'object',
- 'properties' => array(
- 'low_stock_amount' => array( 'type' => 'integer' ),
- 'price' => array( 'type' => 'number' ),
- 'name' => array( 'type' => 'string' ),
- 'on_sale' => array( 'type' => 'boolean' ),
- ),
- )
- );
-
- $schema = $this->invoke_get_output_schema( $controller, 'get' );
-
- $this->assertSame( self::SCALAR_UNION_WITH_NULL, $schema['properties']['low_stock_amount']['type'] );
- $this->assertSame( self::SCALAR_UNION_WITH_NULL, $schema['properties']['price']['type'] );
- $this->assertSame( self::SCALAR_UNION_WITH_NULL, $schema['properties']['name']['type'] );
- $this->assertSame( self::SCALAR_UNION_WITH_NULL, $schema['properties']['on_sale']['type'] );
- }
-
- /**
- * @testdox Should admit cross-scalar values for fields whose declared type disagrees with the actual response.
- *
- * Documents the motivating bug: several WooCommerce REST controllers declare a
- * scalar type that does not match what they return — `shipping_class_id` is
- * declared `string` but returned as `int`, `meta_data[].display_value` is
- * declared `string` but can be array/object, etc. The widened union admits
- * every scalar so structuredContent validation passes regardless of which
- * scalar the controller actually emits.
- */
- public function test_output_schema_admits_cross_scalar_values_for_mismatched_declarations(): void {
- $controller = $this->create_mock_controller_with_item_schema(
- array(
- 'type' => 'object',
- 'properties' => array(
- 'shipping_class_id' => array( 'type' => 'string' ),
- 'image_id' => array( 'type' => 'integer' ),
- ),
- )
- );
-
- $schema = $this->invoke_get_output_schema( $controller, 'get' );
-
- foreach ( array( 'shipping_class_id', 'image_id' ) as $field ) {
- $this->assertSame( self::SCALAR_UNION_WITH_NULL, $schema['properties'][ $field ]['type'] );
- $this->assertContains( 'string', $schema['properties'][ $field ]['type'] );
- $this->assertContains( 'integer', $schema['properties'][ $field ]['type'] );
- $this->assertContains( 'array', $schema['properties'][ $field ]['type'] );
- $this->assertContains( 'object', $schema['properties'][ $field ]['type'] );
- $this->assertContains( 'null', $schema['properties'][ $field ]['type'] );
- }
- }
-
- /**
- * @testdox Should admit array and object values for fields declared as a scalar (meta_data display_value case).
- *
- * `meta_data[].display_value` is declared as `string` in the orders schema
- * but the REST controller returns whatever shape the underlying meta value
- * has — including arrays for variation attributes and serialized custom
- * data. Widening the output union to include `array` and `object` keeps
- * structuredContent validation passing for those rows.
- */
- public function test_output_schema_admits_array_and_object_for_declared_scalar_fields(): void {
- $controller = $this->create_mock_controller_with_item_schema(
- array(
- 'type' => 'object',
- 'properties' => array(
- 'display_value' => array( 'type' => 'string' ),
- ),
- )
- );
-
- $schema = $this->invoke_get_output_schema( $controller, 'get' );
-
- $this->assertContains( 'array', $schema['properties']['display_value']['type'] );
- $this->assertContains( 'object', $schema['properties']['display_value']['type'] );
- }
-
- /**
- * @testdox Should not union null into object or array types in output schema.
- */
- public function test_output_schema_does_not_union_null_into_compound_types(): void {
- $controller = $this->create_mock_controller_with_item_schema(
- array(
- 'type' => 'object',
- 'properties' => array(
- 'meta_data' => array(
- 'type' => 'array',
- 'items' => array( 'type' => 'object' ),
- ),
- 'attributes' => array(
- 'type' => 'object',
- 'properties' => array(),
- ),
- ),
- )
- );
-
- $schema = $this->invoke_get_output_schema( $controller, 'get' );
-
- $this->assertSame( 'array', $schema['properties']['meta_data']['type'], 'array type should remain a single string' );
- $this->assertSame( 'object', $schema['properties']['attributes']['type'], 'object type should remain a single string' );
- }
-
- /**
- * @testdox Should widen pre-existing scalar-plus-null unions to the full output union.
- *
- * `low_stock_amount` is declared as `[integer, null]` in the products
- * schema but the controller returns an empty string when unset (via
- * `set_low_stock_amount('')`), which neither member of the declared union
- * admits. Widening scalar-only unions covers that case.
- */
- public function test_output_schema_widens_pre_existing_scalar_unions(): void {
- $controller = $this->create_mock_controller_with_item_schema(
- array(
- 'type' => 'object',
- 'properties' => array(
- 'low_stock_amount' => array( 'type' => array( 'integer', 'null' ) ),
- ),
- )
- );
-
- $schema = $this->invoke_get_output_schema( $controller, 'get' );
-
- $this->assertSame( self::SCALAR_UNION_WITH_NULL, $schema['properties']['low_stock_amount']['type'] );
- }
-
- /**
- * @testdox Should leave unions containing compound types untouched in output schema.
- *
- * If the schema author declared a union that includes `object` or `array`,
- * trust that — widening would lose information without a known WC quirk to
- * justify it.
- */
- public function test_output_schema_leaves_compound_unions_untouched(): void {
- $controller = $this->create_mock_controller_with_item_schema(
- array(
- 'type' => 'object',
- 'properties' => array(
- 'payload' => array( 'type' => array( 'object', 'null' ) ),
- 'mixed' => array( 'type' => array( 'string', 'array' ) ),
- ),
- )
- );
-
- $schema = $this->invoke_get_output_schema( $controller, 'get' );
-
- $this->assertSame( array( 'object', 'null' ), $schema['properties']['payload']['type'] );
- $this->assertSame( array( 'string', 'array' ), $schema['properties']['mixed']['type'] );
- }
-
- /**
- * @testdox Should relax nested properties and items in output schema.
- */
- public function test_output_schema_relaxes_nested_properties_and_items(): void {
- $controller = $this->create_mock_controller_with_item_schema(
- array(
- 'type' => 'object',
- 'properties' => array(
- 'images' => array(
- 'type' => 'array',
- 'items' => array(
- 'type' => 'object',
- 'properties' => array(
- 'src' => array(
- 'type' => 'string',
- 'format' => 'uri',
- ),
- 'date_created' => array( 'type' => 'date-time' ),
- 'id' => array( 'type' => 'integer' ),
- ),
- ),
- ),
- ),
- )
- );
-
- $schema = $this->invoke_get_output_schema( $controller, 'get' );
-
- $image = $schema['properties']['images']['items']['properties'];
- $this->assertArrayNotHasKey( 'format', $image['src'], 'Nested uri format should be stripped' );
- $this->assertArrayNotHasKey( 'format', $image['date_created'], 'Nested date-time format should be stripped' );
- $this->assertSame( self::SCALAR_UNION_WITH_NULL, $image['id']['type'], 'Nested scalar should be widened to the full scalar union with null' );
- }
-
- /**
- * @testdox Should strip format date-time set directly on a property (post-sanitization shape).
- */
- public function test_output_schema_strips_format_date_time_when_set_directly(): void {
- $controller = $this->create_mock_controller_with_item_schema(
- array(
- 'type' => 'object',
- 'properties' => array(
- 'date_created' => array(
- 'type' => 'string',
- 'format' => 'date-time',
- ),
- ),
- )
- );
-
- $schema = $this->invoke_get_output_schema( $controller, 'get' );
-
- $this->assertArrayNotHasKey( 'format', $schema['properties']['date_created'], 'format: date-time should be stripped when declared directly, not only when arrived via the date-time pseudo-type' );
- }
-
- /**
- * @testdox Should strip format date-time and uri even when the property has no type key.
- */
- public function test_output_schema_strips_format_when_no_type_key(): void {
- $controller = $this->create_mock_controller_with_item_schema(
- array(
- 'type' => 'object',
- 'properties' => array(
- 'untyped_date' => array( 'format' => 'date-time' ),
- 'untyped_uri' => array( 'format' => 'uri' ),
- ),
- )
- );
-
- $schema = $this->invoke_get_output_schema( $controller, 'get' );
-
- $this->assertArrayNotHasKey( 'format', $schema['properties']['untyped_date'] );
- $this->assertArrayNotHasKey( 'format', $schema['properties']['untyped_uri'] );
- }
-
- /**
- * @testdox Should relax the inner schema embedded in the delete operation wrapper.
- */
- public function test_output_schema_delete_operation_relaxes_previous_schema(): void {
- $controller = $this->create_mock_controller_with_item_schema(
- array(
- 'type' => 'object',
- 'properties' => array(
- 'id' => array( 'type' => 'integer' ),
- 'date_created' => array(
- 'type' => 'string',
- 'format' => 'date-time',
- ),
- ),
- )
- );
-
- $schema = $this->invoke_get_output_schema( $controller, 'delete' );
-
- $this->assertSame( 'object', $schema['type'], 'delete wrapper outer type should remain object' );
- $this->assertSame( 'boolean', $schema['properties']['deleted']['type'], 'delete wrapper deleted flag should remain a single boolean' );
-
- $previous = $schema['properties']['previous'];
- $this->assertSame( self::SCALAR_UNION_WITH_NULL, $previous['properties']['id']['type'], 'previous schema should have scalar widened to the full scalar union with null' );
- $this->assertArrayNotHasKey( 'format', $previous['properties']['date_created'], 'previous schema should have date-time format stripped' );
- }
-
- /**
- * @testdox Should strip formats inside combiner sub-schemas but skip null-union there.
- */
- public function test_output_schema_relaxes_combiner_sub_schemas(): void {
- $controller = $this->create_mock_controller_with_item_schema(
- array(
- 'type' => 'object',
- 'properties' => array(
- 'either' => array(
- 'anyOf' => array(
- array(
- 'type' => 'string',
- 'format' => 'date-time',
- ),
- array( 'type' => 'integer' ),
- ),
- ),
- 'one_of' => array(
- 'oneOf' => array(
- array(
- 'type' => 'string',
- 'format' => 'uri',
- ),
- ),
- ),
- 'all_of' => array(
- 'allOf' => array(
- array( 'type' => 'number' ),
- ),
- ),
- ),
- )
- );
-
- $schema = $this->invoke_get_output_schema( $controller, 'get' );
-
- $this->assertArrayNotHasKey( 'format', $schema['properties']['either']['anyOf'][0], 'anyOf branch should have format: date-time stripped' );
- $this->assertSame( 'string', $schema['properties']['either']['anyOf'][0]['type'], 'null should not be unioned inside combiner branches' );
- $this->assertSame( 'integer', $schema['properties']['either']['anyOf'][1]['type'] );
- $this->assertArrayNotHasKey( 'format', $schema['properties']['one_of']['oneOf'][0], 'oneOf branch should have format: uri stripped' );
- $this->assertSame( 'string', $schema['properties']['one_of']['oneOf'][0]['type'] );
- $this->assertSame( 'number', $schema['properties']['all_of']['allOf'][0]['type'] );
- }
-
- /**
- * @testdox Should preserve oneOf semantics by not adding null to every branch.
- */
- public function test_output_schema_preserves_oneof_exactly_one_semantics(): void {
- $controller = $this->create_mock_controller_with_item_schema(
- array(
- 'type' => 'object',
- 'properties' => array(
- 'value' => array(
- 'oneOf' => array(
- array( 'type' => 'string' ),
- array( 'type' => 'integer' ),
- ),
- ),
- ),
- )
- );
-
- $schema = $this->invoke_get_output_schema( $controller, 'get' );
-
- $branches = $schema['properties']['value']['oneOf'];
- $this->assertSame( 'string', $branches[0]['type'], 'oneOf branches must remain non-nullable so null does not match every branch and break the "exactly one" rule' );
- $this->assertSame( 'integer', $branches[1]['type'] );
- }
-
- /**
- * @testdox Should not null-union scalars nested deep inside a oneOf branch.
- */
- public function test_output_schema_propagates_no_null_union_into_nested_oneof_descendants(): void {
- $controller = $this->create_mock_controller_with_item_schema(
- array(
- 'type' => 'object',
- 'properties' => array(
- 'shape' => array(
- 'oneOf' => array(
- array(
- 'type' => 'object',
- 'properties' => array(
- 'value' => array( 'type' => 'string' ),
- ),
- ),
- array(
- 'type' => 'object',
- 'properties' => array(
- 'value' => array( 'type' => 'integer' ),
- ),
- ),
- ),
- ),
- ),
- )
- );
-
- $schema = $this->invoke_get_output_schema( $controller, 'get' );
-
- $branches = $schema['properties']['shape']['oneOf'];
- $this->assertSame( 'string', $branches[0]['properties']['value']['type'], 'Nested property inside oneOf branch must not be unioned with null; otherwise {"value": null} matches both branches and breaks oneOf semantics' );
- $this->assertSame( 'integer', $branches[1]['properties']['value']['type'] );
- }
-
- /**
- * @testdox Should normalize date-time pseudo-type inside tuple-form items.
- */
- public function test_sanitize_schema_normalizes_tuple_form_items(): void {
- $controller = $this->create_mock_controller_with_item_schema(
- array(
- 'type' => 'object',
- 'properties' => array(
- 'pair' => array(
- 'type' => 'array',
- 'items' => array(
- array( 'type' => 'date-time' ),
- array( 'type' => 'integer' ),
- ),
- ),
- ),
- )
- );
-
- $schema = $this->invoke_get_output_schema( $controller, 'get' );
-
- $entries = $schema['properties']['pair']['items'];
- $this->assertSame(
- self::SCALAR_UNION_WITH_NULL,
- $entries[0]['type'],
- 'date-time pseudo-type must be normalized to string by sanitize_schema, then widened by relax'
- );
- $this->assertArrayNotHasKey( 'format', $entries[0], 'format: date-time emitted by sanitize_schema must then be stripped by relax' );
- }
-
- /**
- * @testdox Should relax each entry of a tuple-form items array.
- */
- public function test_output_schema_relaxes_tuple_form_items(): void {
- $controller = $this->create_mock_controller_with_item_schema(
- array(
- 'type' => 'object',
- 'properties' => array(
- 'pair' => array(
- 'type' => 'array',
- 'items' => array(
- array(
- 'type' => 'string',
- 'format' => 'date-time',
- ),
- array( 'type' => 'integer' ),
- ),
- ),
- ),
- )
- );
-
- $schema = $this->invoke_get_output_schema( $controller, 'get' );
-
- $items = $schema['properties']['pair']['items'];
- $this->assertArrayNotHasKey( 'format', $items[0], 'first tuple entry should have format stripped' );
- $this->assertSame( self::SCALAR_UNION_WITH_NULL, $items[0]['type'], 'tuple entries are positional and not combiner branches, so scalar widening applies' );
- $this->assertSame( self::SCALAR_UNION_WITH_NULL, $items[1]['type'] );
- }
-
- /**
- * @testdox Should relax sub-schemas declared on additionalProperties.
- */
- public function test_output_schema_relaxes_additional_properties(): void {
- $controller = $this->create_mock_controller_with_item_schema(
- array(
- 'type' => 'object',
- 'additionalProperties' => array(
- 'type' => 'string',
- 'format' => 'uri',
- ),
- )
- );
-
- $schema = $this->invoke_get_output_schema( $controller, 'get' );
-
- $this->assertArrayNotHasKey( 'format', $schema['additionalProperties'], 'additionalProperties schema should have format: uri stripped' );
- $this->assertSame( self::SCALAR_UNION_WITH_NULL, $schema['additionalProperties']['type'] );
- }
-
- /**
- * @testdox Should relax inner items for list operation output schema.
- */
- public function test_output_schema_list_operation_relaxes_inner_items(): void {
- $controller = $this->create_mock_controller_with_item_schema(
- array(
- 'type' => 'object',
- 'properties' => array(
- 'id' => array( 'type' => 'integer' ),
- 'date_created' => array( 'type' => 'date-time' ),
- ),
- )
- );
-
- $schema = $this->invoke_get_output_schema( $controller, 'list' );
-
- $this->assertSame( 'object', $schema['type'], 'list wrapper outer type should remain object' );
- $this->assertSame( 'array', $schema['properties']['data']['type'], 'list wrapper data property should remain array' );
-
- $item = $schema['properties']['data']['items'];
- $this->assertSame( self::SCALAR_UNION_WITH_NULL, $item['properties']['id']['type'] );
- $this->assertArrayNotHasKey( 'format', $item['properties']['date_created'] );
- }
-}
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/RemovedRestAbilitiesTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/RemovedRestAbilitiesTest.php
new file mode 100644
index 00000000000..3ad62f14dba
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/RemovedRestAbilitiesTest.php
@@ -0,0 +1,95 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\Abilities;
+
+use Automattic\WooCommerce\Internal\Abilities\AbilitiesRestBridge;
+use Automattic\WooCommerce\Internal\Abilities\REST\RestAbility;
+use Automattic\WooCommerce\Internal\Abilities\REST\RestAbilityFactory;
+use WC_Unit_Test_Case;
+
+/**
+ * Tests for the polyfills left in place of the removed REST-derived abilities.
+ */
+class RemovedRestAbilitiesTest extends WC_Unit_Test_Case {
+
+ /**
+ * @testdox Should report incorrect usage without registering abilities when a removed method is called.
+ *
+ * @dataProvider removed_method_provider
+ *
+ * @param string $method The removed method, as reported to _doing_it_wrong().
+ * @param callable $callback Calls the removed method.
+ */
+ public function test_removed_methods_report_incorrect_usage( string $method, callable $callback ): void {
+ $this->setExpectedIncorrectUsage( $method );
+ $abilities_before = function_exists( 'wp_get_abilities' ) ? array_keys( wp_get_abilities() ) : array();
+
+ $callback();
+
+ $abilities_after = function_exists( 'wp_get_abilities' ) ? array_keys( wp_get_abilities() ) : array();
+ $this->assertSame( $abilities_before, $abilities_after, 'Removed methods should not register abilities' );
+ }
+
+ /**
+ * Data provider for removed methods.
+ *
+ * @return array<string, array{string, callable}>
+ */
+ public function removed_method_provider(): array {
+ return array(
+ 'RestAbilityFactory::register_controller_abilities' => array(
+ RestAbilityFactory::class . '::register_controller_abilities',
+ static function () {
+ RestAbilityFactory::register_controller_abilities(
+ array(
+ 'controller' => \WC_REST_Products_Controller::class,
+ 'route' => '/wc/v3/products',
+ 'abilities' => array(),
+ )
+ );
+ },
+ ),
+ 'AbilitiesRestBridge::init' => array(
+ AbilitiesRestBridge::class . '::init',
+ static function () {
+ AbilitiesRestBridge::init();
+ },
+ ),
+ 'AbilitiesRestBridge::register_abilities' => array(
+ AbilitiesRestBridge::class . '::register_abilities',
+ static function () {
+ AbilitiesRestBridge::register_abilities();
+ },
+ ),
+ );
+ }
+
+ /**
+ * @testdox Should keep RestAbility usable while reporting incorrect usage.
+ */
+ public function test_rest_ability_still_constructs(): void {
+ $this->setExpectedIncorrectUsage( RestAbility::class );
+
+ $ability = new RestAbility(
+ 'woocommerce-test/removed-rest-ability',
+ array(
+ 'label' => 'Test',
+ 'description' => 'Test',
+ 'category' => 'woocommerce',
+ 'execute_callback' => '__return_true',
+ 'permission_callback' => '__return_true',
+ )
+ );
+
+ $this->assertSame( 'woocommerce-test/removed-rest-ability', $ability->get_name() );
+ }
+
+ /**
+ * @testdox Should keep the removed exposure metadata key constant.
+ */
+ public function test_metadata_key_constant_is_kept(): void {
+ $this->assertSame( 'expose_in_deprecated_woocommerce_mcp', RestAbilityFactory::EXPOSE_IN_DEPRECATED_MCP_META_KEY );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/AbilitiesApiIntegrationTest.php b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/AbilitiesApiIntegrationTest.php
index 8d8c6b9f73b..3d7f9901191 100644
--- a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/AbilitiesApiIntegrationTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/AbilitiesApiIntegrationTest.php
@@ -67,17 +67,6 @@ class AbilitiesApiIntegrationTest extends \WC_REST_Unit_Test_Case {
$this->abilities_categories_init_action = 'wp_abilities_api_categories_init';
$this->category_registry_class = 'WP_Ability_Categories_Registry';
- /*
- * Explicitly ensure the abilities API bootstrap file is loaded for tests.
- * WordPress 6.9+ has abilities API in core, so we don't need to load the vendor version.
- */
- if ( ! function_exists( 'wp_register_ability' ) ) {
- $bootstrap_file = WP_PLUGIN_DIR . '/woocommerce/vendor/wordpress/abilities-api/includes/bootstrap.php';
- if ( file_exists( $bootstrap_file ) ) {
- require $bootstrap_file;
- }
- }
-
// WordPress 6.9+ requires did_action('init') to return truthy before abilities API can be used.
// Save the original value and set to 1. Doesn't hurt pre-6.9 versions.
$this->original_init_action_count = $wp_actions['init'] ?? null;
diff --git a/plugins/woocommerce/tests/php/src/Internal/MCP/MCPAdapterProviderTest.php b/plugins/woocommerce/tests/php/src/Internal/MCP/MCPAdapterProviderTest.php
deleted file mode 100644
index ad19c06d849..00000000000
--- a/plugins/woocommerce/tests/php/src/Internal/MCP/MCPAdapterProviderTest.php
+++ /dev/null
@@ -1,545 +0,0 @@
-<?php
-/**
- * MCPAdapterProviderTest class file.
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\Tests\Internal\MCP;
-
-use Automattic\WooCommerce\Internal\Abilities\AbilitiesRegistry;
-use Automattic\WooCommerce\Internal\Abilities\REST\RestAbilityFactory;
-use Automattic\WooCommerce\Internal\MCP\MCPAdapterProvider;
-use Automattic\WooCommerce\Utilities\FeaturesUtil;
-
-/**
- * Tests for the MCPAdapterProvider class.
- */
-class MCPAdapterProviderTest extends \WC_Unit_Test_Case {
-
- /**
- * The system under test.
- *
- * @var MCPAdapterProvider
- */
- private $sut;
-
- /**
- * Mock abilities registry.
- *
- * @var AbilitiesRegistry|\PHPUnit\Framework\MockObject\MockObject
- */
- private $mock_abilities_registry;
-
- /**
- * Original abilities registry instance.
- *
- * @var AbilitiesRegistry
- */
- private $original_abilities_registry;
-
- /**
- * Ability IDs registered by these tests.
- *
- * @var array
- */
- private $registered_ability_ids = array();
-
- /**
- * Ability category IDs registered by these tests.
- *
- * @var array
- */
- private $registered_ability_category_ids = array();
-
- /**
- * Original value of $wp_actions['wp_abilities_api_init'] to restore in tearDown.
- *
- * @var int|null
- */
- private $original_wp_abilities_api_init_action_count;
-
- /**
- * Original value of $wp_actions['wp_abilities_api_categories_init'] to restore in tearDown.
- *
- * @var int|null
- */
- private $original_wp_abilities_api_categories_init_action_count;
-
- /**
- * Original value of $wp_actions['init'] to restore in tearDown.
- *
- * @var int|null
- */
- private $original_init_action_count;
-
- /**
- * Set up before each test.
- */
- public function setUp(): void {
- global $wp_actions;
-
- parent::setUp();
-
- $this->original_init_action_count = $wp_actions['init'] ?? null;
- $this->original_wp_abilities_api_init_action_count = $wp_actions['wp_abilities_api_init'] ?? null;
- $this->original_wp_abilities_api_categories_init_action_count = $wp_actions['wp_abilities_api_categories_init'] ?? null;
-
- // WordPress 6.9+ requires init to have fired before the Abilities API registry can be initialized.
- $wp_actions['init'] = max( 1, (int) ( $wp_actions['init'] ?? 0 ) ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
-
- // Bootstrap the WordPress Abilities API for tests.
- if ( ! function_exists( 'wp_register_ability' ) ) {
- $abilities_bootstrap = WP_PLUGIN_DIR . '/woocommerce/vendor/wordpress/abilities-api/includes/bootstrap.php';
- if ( file_exists( $abilities_bootstrap ) ) {
- require_once $abilities_bootstrap;
- }
- }
-
- // Bootstrap the MCP Adapter for tests.
- if ( ! class_exists( 'WP\\MCP\\Core\\McpAdapter' ) ) {
- $mcp_bootstrap = WP_PLUGIN_DIR . '/woocommerce/vendor/wordpress/mcp-adapter/includes/Autoloader.php';
- if ( file_exists( $mcp_bootstrap ) ) {
- require_once $mcp_bootstrap;
- // Initialize the autoloader.
- if ( class_exists( 'WP\\MCP\\Autoloader' ) ) {
- \WP\MCP\Autoloader::autoload();
- }
- }
- }
-
- // Create mock abilities registry.
- $this->mock_abilities_registry = $this->createMock( AbilitiesRegistry::class );
-
- // Capture original abilities registry before replacing.
- $container = wc_get_container();
- $this->original_abilities_registry = $container->get( AbilitiesRegistry::class );
-
- // Replace in container for testing.
- $container->replace( AbilitiesRegistry::class, $this->mock_abilities_registry );
-
- $this->sut = new MCPAdapterProvider();
- }
-
- /**
- * Clean up after each test.
- */
- public function tearDown(): void {
- global $wp_actions;
-
- // Restore original abilities registry if it was captured.
- if ( $this->original_abilities_registry ) {
- $container = wc_get_container();
- $container->replace( AbilitiesRegistry::class, $this->original_abilities_registry );
- $this->original_abilities_registry = null;
- }
-
- // Reset any filters that might have been added.
- remove_all_filters( 'woocommerce_mcp_include_ability' );
- remove_all_filters( 'woocommerce_mcp_allow_insecure_transport' );
-
- foreach ( $this->registered_ability_ids as $ability_id ) {
- if ( function_exists( 'wp_unregister_ability' ) ) {
- wp_unregister_ability( $ability_id );
- }
- }
- $this->registered_ability_ids = array();
-
- foreach ( $this->registered_ability_category_ids as $category_id ) {
- if ( function_exists( 'wp_unregister_ability_category' ) ) {
- wp_unregister_ability_category( $category_id );
- }
- }
- $this->registered_ability_category_ids = array();
-
- // Remove actions registered by the system under test.
- remove_action( 'rest_api_init', array( $this->sut, 'maybe_initialize' ), 10 );
- remove_action( 'mcp_adapter_init', array( $this->sut, 'initialize_mcp_server' ), 10 );
-
- // Clean up feature flag options.
- delete_option( 'woocommerce_feature_mcp_integration_enabled' );
-
- if ( null !== $this->original_wp_abilities_api_init_action_count ) {
- $wp_actions['wp_abilities_api_init'] = $this->original_wp_abilities_api_init_action_count; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
- } elseif ( isset( $wp_actions['wp_abilities_api_init'] ) ) {
- unset( $wp_actions['wp_abilities_api_init'] ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
- }
-
- if ( null !== $this->original_wp_abilities_api_categories_init_action_count ) {
- $wp_actions['wp_abilities_api_categories_init'] = $this->original_wp_abilities_api_categories_init_action_count; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
- } elseif ( isset( $wp_actions['wp_abilities_api_categories_init'] ) ) {
- unset( $wp_actions['wp_abilities_api_categories_init'] ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
- }
-
- if ( null !== $this->original_init_action_count ) {
- $wp_actions['init'] = $this->original_init_action_count; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
- } elseif ( isset( $wp_actions['init'] ) ) {
- unset( $wp_actions['init'] ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
- }
-
- parent::tearDown();
- }
-
- /**
- * @testdox Should not initialize when the MCP feature flag is disabled.
- */
- public function test_maybe_initialize_respects_feature_flag_disabled(): void {
- // Ensure MCP feature is disabled via option.
- update_option( 'woocommerce_feature_mcp_integration_enabled', 'no' );
-
- $this->sut->maybe_initialize();
-
- $this->assertFalse( $this->sut->is_initialized(), 'Should not initialize when feature flag is disabled' );
- }
-
- /**
- * @testdox Should initialize when the MCP feature flag is enabled.
- */
- public function test_maybe_initialize_respects_feature_flag_enabled(): void {
-
- // Enable MCP feature via option.
- update_option( 'woocommerce_feature_mcp_integration_enabled', 'yes' );
-
- $this->sut->maybe_initialize();
-
- $this->assertTrue( $this->sut->is_initialized(), 'Should initialize when feature flag is enabled' );
- }
-
- /**
- * @testdox Should prevent double initialization.
- */
- public function test_prevents_double_initialization(): void {
- // Enable MCP feature via option.
- update_option( 'woocommerce_feature_mcp_integration_enabled', 'yes' );
-
- $this->sut->maybe_initialize();
- $first_initialized = $this->sut->is_initialized();
-
- // Try to initialize again.
- $this->sut->maybe_initialize();
- $second_initialized = $this->sut->is_initialized();
-
- $this->assertEquals( $first_initialized, $second_initialized, 'Should prevent double initialization' );
- }
-
- /**
- * @testdox Should include marked deprecated WooCommerce MCP abilities across namespaces.
- */
- public function test_get_woocommerce_mcp_abilities_includes_marked_abilities_across_namespaces(): void {
- $exposed_woocommerce_ability = 'woocommerce/test-deprecated-products-a';
- $exposed_external_ability = 'custom-plugin/test-deprecated-orders-a';
-
- $this->register_test_ability(
- $exposed_woocommerce_ability,
- array(
- RestAbilityFactory::EXPOSE_IN_DEPRECATED_MCP_META_KEY => true,
- )
- );
- $this->register_test_ability(
- $exposed_external_ability,
- array(
- RestAbilityFactory::EXPOSE_IN_DEPRECATED_MCP_META_KEY => true,
- )
- );
-
- $this->mock_abilities_registry
- ->method( 'get_abilities_ids' )
- ->willReturn(
- array(
- 'unregistered-plugin/not-available',
- $exposed_woocommerce_ability,
- $exposed_external_ability,
- )
- );
-
- $result = $this->get_woocommerce_mcp_abilities();
-
- $this->assertCount( 2, $result, 'Should only return abilities explicitly exposed in the deprecated WooCommerce MCP endpoint.' );
- $this->assertContains( $exposed_woocommerce_ability, $result, 'Should include marked WooCommerce abilities.' );
- $this->assertContains( $exposed_external_ability, $result, 'Should include marked abilities from other namespaces.' );
- $this->assertSame( array( 0, 1 ), array_keys( $result ), 'Should re-index array after filtering.' );
- }
-
- /**
- * @testdox Should exclude unmarked WooCommerce abilities from the deprecated MCP endpoint.
- */
- public function test_get_woocommerce_mcp_abilities_excludes_unmarked_woocommerce_abilities(): void {
- $unmarked_ability = 'woocommerce/test-unmarked';
-
- $this->register_test_ability(
- $unmarked_ability,
- array()
- );
-
- $this->mock_abilities_registry
- ->method( 'get_abilities_ids' )
- ->willReturn(
- array(
- $unmarked_ability,
- )
- );
-
- $result = $this->get_woocommerce_mcp_abilities();
-
- $this->assertEmpty( $result, 'Should exclude unmarked WooCommerce abilities.' );
- }
-
- /**
- * @testdox Should require strict boolean metadata for deprecated MCP endpoint exposure.
- */
- public function test_get_woocommerce_mcp_abilities_excludes_false_or_non_boolean_exposure_metadata(): void {
- $opted_out_ability = 'woocommerce/test-opted-out';
- $invalid_exposure_ability = 'woocommerce/test-invalid-exposure';
-
- $this->register_test_ability(
- $opted_out_ability,
- array(
- RestAbilityFactory::EXPOSE_IN_DEPRECATED_MCP_META_KEY => false,
- )
- );
- $this->register_test_ability(
- $invalid_exposure_ability,
- array(
- RestAbilityFactory::EXPOSE_IN_DEPRECATED_MCP_META_KEY => 'true',
- )
- );
-
- $this->mock_abilities_registry
- ->method( 'get_abilities_ids' )
- ->willReturn(
- array(
- $opted_out_ability,
- $invalid_exposure_ability,
- )
- );
-
- $result = $this->get_woocommerce_mcp_abilities();
-
- $this->assertEmpty( $result, 'Should exclude false and non-boolean exposure metadata.' );
- }
-
- /**
- * @testdox Should respect custom filters for deprecated WooCommerce MCP ability inclusion.
- */
- public function test_get_woocommerce_mcp_abilities_respects_custom_filter(): void {
- $deprecated_ability = 'woocommerce/test-custom-filter-deprecated';
-
- $this->register_test_ability(
- $deprecated_ability,
- array(
- RestAbilityFactory::EXPOSE_IN_DEPRECATED_MCP_META_KEY => true,
- )
- );
-
- // Mock abilities registry to return test abilities.
- $this->mock_abilities_registry
- ->method( 'get_abilities_ids' )
- ->willReturn(
- array(
- $deprecated_ability,
- 'custom-plugin/special-action',
- 'other-plugin/normal-action',
- )
- );
-
- // Add custom filter to include abilities from custom-plugin namespace.
- add_filter(
- 'woocommerce_mcp_include_ability',
- function ( $should_include, $ability_id ) {
- if ( 'woocommerce/test-custom-filter-deprecated' === $ability_id ) {
- return false;
- }
- if ( str_starts_with( $ability_id, 'custom-plugin/' ) ) {
- return true;
- }
- return $should_include;
- },
- 10,
- 2
- );
-
- $result = $this->get_woocommerce_mcp_abilities();
-
- $this->assertCount( 1, $result, 'Should only return abilities included by the custom filter.' );
- $this->assertContains( 'custom-plugin/special-action', $result, 'Should include abilities opted in by filter.' );
- $this->assertNotContains( $deprecated_ability, $result, 'Should exclude abilities opted out by filter.' );
- $this->assertSame( array( 0 ), array_keys( $result ), 'Should re-index array after filter override.' );
- }
-
- /**
- * @testdox Should track initialization state.
- */
- public function test_is_initialized_tracks_state(): void {
- $this->assertFalse( $this->sut->is_initialized(), 'Should start as not initialized' );
-
- // Enable MCP feature via option.
- update_option( 'woocommerce_feature_mcp_integration_enabled', 'yes' );
-
- $this->sut->maybe_initialize();
- $this->assertTrue( $this->sut->is_initialized(), 'Should track initialized state' );
- }
-
- /**
- * @testdox Should handle an empty abilities array.
- */
- public function test_handles_empty_abilities_array(): void {
- // Mock abilities registry to return empty array.
- $this->mock_abilities_registry
- ->method( 'get_abilities_ids' )
- ->willReturn( array() );
-
- $result = $this->get_woocommerce_mcp_abilities();
-
- $this->assertEmpty( $result, 'Should handle empty abilities array correctly' );
- }
-
- /**
- * @testdox Should filter out unregistered abilities.
- */
- public function test_filters_out_unregistered_abilities(): void {
- $this->mock_abilities_registry
- ->method( 'get_abilities_ids' )
- ->willReturn(
- array(
- 'other-plugin/action-1',
- 'another-namespace/action-2',
- 'custom/action-3',
- )
- );
-
- $result = $this->get_woocommerce_mcp_abilities();
-
- $this->assertEmpty( $result, 'Should filter out all unregistered abilities.' );
- }
-
- /**
- * Get WooCommerce MCP abilities through the private provider method.
- *
- * @return array
- */
- private function get_woocommerce_mcp_abilities(): array {
- $reflection = new \ReflectionClass( $this->sut );
- $method = $reflection->getMethod( 'get_woocommerce_mcp_abilities' );
- $method->setAccessible( true );
-
- return $method->invoke( $this->sut );
- }
-
- /**
- * Register a minimal ability for provider tests.
- *
- * @param string $ability_id Ability ID.
- * @param array $meta Ability meta.
- */
- private function register_test_ability( string $ability_id, array $meta ): void {
- $this->ensure_test_ability_category( 'woocommerce-rest' );
- $this->ensure_abilities_registry_initialized();
-
- $ability = null;
- $callback = null;
- $callback = function () use ( &$ability, $ability_id, $meta, &$callback ) {
- remove_action( 'wp_abilities_api_init', $callback );
-
- $ability = wp_register_ability(
- $ability_id,
- array(
- 'label' => 'Test ability',
- 'description' => 'Test ability.',
- 'category' => 'woocommerce-rest',
- 'input_schema' => array( 'type' => 'object' ),
- 'output_schema' => array( 'type' => 'object' ),
- 'execute_callback' => static function () {
- return array();
- },
- 'permission_callback' => static function () {
- return true;
- },
- 'meta' => array_merge(
- array(
- 'show_in_rest' => true,
- ),
- $meta
- ),
- )
- );
- };
-
- add_action( 'wp_abilities_api_init', $callback );
- do_action( 'wp_abilities_api_init' );
- remove_action( 'wp_abilities_api_init', $callback );
-
- $this->assertNotWPError( $ability, 'Test ability should register successfully.' );
- $this->assertNotNull( $ability, 'Test ability should register successfully.' );
- $this->registered_ability_ids[] = $ability_id;
- }
-
- /**
- * Register the test ability category if the suite has not already registered it.
- *
- * @param string $category_id Ability category ID.
- */
- private function ensure_test_ability_category( string $category_id ): void {
- if ( ! function_exists( 'wp_register_ability_category' ) || ! function_exists( 'wp_has_ability_category' ) ) {
- return;
- }
- $this->ensure_ability_categories_registry_initialized();
-
- if ( wp_has_ability_category( $category_id ) ) {
- return;
- }
-
- $category = null;
- $callback = null;
- $callback = function () use ( &$category, $category_id, &$callback ) {
- remove_action( 'wp_abilities_api_categories_init', $callback );
-
- if ( wp_has_ability_category( $category_id ) ) {
- return;
- }
-
- $category = wp_register_ability_category(
- $category_id,
- array(
- 'label' => 'WooCommerce REST API',
- 'description' => 'REST API operations for WooCommerce resources.',
- )
- );
- };
-
- add_action( 'wp_abilities_api_categories_init', $callback );
- do_action( 'wp_abilities_api_categories_init' );
- remove_action( 'wp_abilities_api_categories_init', $callback );
-
- if ( null !== $category ) {
- $this->assertNotWPError( $category, 'Test ability category should register successfully.' );
- $this->assertNotNull( $category, 'Test ability category should register successfully.' );
- $this->registered_ability_category_ids[] = $category_id;
- }
-
- $this->assertTrue( wp_has_ability_category( $category_id ), 'Test ability category should be available.' );
- }
-
- /**
- * Ensure the ability registry is ready before adding one-off test callbacks.
- *
- * Registry initialization fires wp_abilities_api_init. If the test callback is
- * already attached, the same ability can be registered twice and return null.
- */
- private function ensure_abilities_registry_initialized(): void {
- if ( class_exists( '\WP_Abilities_Registry' ) ) {
- \WP_Abilities_Registry::get_instance();
- }
- }
-
- /**
- * Ensure the ability category registry is ready before adding one-off test callbacks.
- *
- * Registry initialization fires wp_abilities_api_categories_init. If the test
- * callback is already attached, the same category can be registered twice.
- */
- private function ensure_ability_categories_registry_initialized(): void {
- if ( class_exists( '\WP_Ability_Categories_Registry' ) ) {
- \WP_Ability_Categories_Registry::get_instance();
- }
- }
-}
diff --git a/plugins/woocommerce/tests/php/src/Internal/MCP/McpEndpointRemovalTest.php b/plugins/woocommerce/tests/php/src/Internal/MCP/McpEndpointRemovalTest.php
new file mode 100644
index 00000000000..a10ec0bbe40
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/MCP/McpEndpointRemovalTest.php
@@ -0,0 +1,318 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\MCP;
+
+use Automattic\WooCommerce\Internal\Features\FeaturesController;
+use Automattic\WooCommerce\Internal\MCP\McpEndpointRemoval;
+use WC_Unit_Test_Case;
+use WP_REST_Request;
+
+/**
+ * Tests for the McpEndpointRemoval class.
+ */
+class McpEndpointRemovalTest extends WC_Unit_Test_Case {
+
+ /**
+ * The System Under Test.
+ *
+ * @var McpEndpointRemoval
+ */
+ private $sut;
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ $this->sut = new McpEndpointRemoval();
+ }
+
+ /**
+ * Tear down test fixtures.
+ */
+ public function tearDown(): void {
+ try {
+ // LegacyProxy mocks live on the container instance, which no base class resets.
+ $this->reset_legacy_proxy_mocks();
+ // Script queues are globals that no base class resets.
+ wp_dequeue_script( 'plugin-install' );
+ wp_dequeue_script( 'thickbox' );
+ wp_dequeue_style( 'thickbox' );
+ } finally {
+ parent::tearDown();
+ }
+ }
+
+ /**
+ * @testdox Should answer MCP requests to the removed endpoint with a JSON-RPC error and HTTP 410.
+ */
+ public function test_removed_endpoint_returns_json_rpc_error(): void {
+ $request = new WP_REST_Request( 'POST', '/woocommerce/mcp' );
+ $request->set_header( 'Content-Type', 'application/json' );
+ $request->set_body(
+ wp_json_encode(
+ array(
+ 'jsonrpc' => '2.0',
+ 'id' => 7,
+ 'method' => 'initialize',
+ )
+ )
+ );
+
+ $response = rest_do_request( $request );
+ $data = $response->get_data();
+
+ $this->assertSame( 410, $response->get_status(), 'The removed endpoint should respond with 410 Gone' );
+ $this->assertSame( '2.0', $data['jsonrpc'] );
+ $this->assertSame( 7, $data['id'], 'The JSON-RPC request id should be echoed' );
+ $this->assertSame( -32000, $data['error']['code'] );
+ $this->assertStringContainsString( 'MCP Adapter plugin', $data['error']['message'] );
+ $this->assertStringContainsString( 'mcp/mcp-adapter-default-server', $data['error']['data']['replacement_endpoint'] );
+ $this->assertSame( McpEndpointRemoval::DOCS_URL, $data['error']['data']['documentation_url'] );
+ }
+
+ /**
+ * @testdox Should answer non-JSON-RPC requests to the removed endpoint with a null JSON-RPC id.
+ */
+ public function test_removed_endpoint_handles_requests_without_json_rpc_id(): void {
+ $response = rest_do_request( new WP_REST_Request( 'GET', '/woocommerce/mcp' ) );
+
+ $this->assertSame( 410, $response->get_status() );
+ $this->assertNull( $response->get_data()['id'], 'Requests without a JSON-RPC id should get a null id' );
+ }
+
+ /**
+ * @testdox Should report the WooCommerce MCP feature as disabled even when its old option is enabled.
+ */
+ public function test_mcp_feature_is_always_disabled(): void {
+ update_option( 'woocommerce_feature_mcp_integration_enabled', 'yes' );
+
+ $this->assertFalse( wc_get_container()->get( FeaturesController::class )->feature_is_enabled( 'mcp_integration' ) );
+ }
+
+ /**
+ * @testdox Should tell stores that had WooCommerce MCP enabled what was removed, with docs and install links.
+ */
+ public function test_removed_notice_shown_when_feature_was_enabled(): void {
+ $this->set_up_removed_notice_conditions( false );
+
+ $output = $this->get_admin_notices_output();
+
+ $this->assertStringContainsString( 'WooCommerce MCP has been removed.', $output );
+ $this->assertStringContainsString( McpEndpointRemoval::DOCS_URL, $output );
+ $this->assertStringContainsString( 'Install MCP Adapter', $output, 'Should link to the installer when no MCP adapter is loaded' );
+ $this->assertStringContainsString( 'tab=plugin-information&plugin=mcp-adapter', $output, 'Should link to the MCP Adapter plugin details, not a search' );
+ $this->assertStringContainsString( 'class="thickbox open-plugin-details-modal"', $output, 'Should open the plugin details in a modal' );
+ $this->assertStringContainsString( 'wc-hide-notice=' . McpEndpointRemoval::REMOVED_NOTICE, $output );
+ }
+
+ /**
+ * @testdox Should leave out the install link when an MCP adapter is already loaded.
+ */
+ public function test_removed_notice_omits_install_link_when_adapter_loaded(): void {
+ $this->set_up_removed_notice_conditions( true );
+
+ $output = $this->get_admin_notices_output();
+
+ $this->assertStringContainsString( 'WooCommerce MCP has been removed.', $output );
+ $this->assertStringNotContainsString( 'Install MCP Adapter', $output );
+ }
+
+ /**
+ * @testdox Should load the plugin details modal scripts only when the notice shows the install link.
+ *
+ * @testWith [false, true]
+ * [true, false]
+ *
+ * @param bool $adapter_loaded Whether an MCP adapter appears to be loaded.
+ * @param bool $expect_enqueued Whether the modal scripts should be enqueued.
+ */
+ public function test_modal_scripts_enqueued_only_with_install_link( bool $adapter_loaded, bool $expect_enqueued ): void {
+ $this->set_up_removed_notice_conditions( $adapter_loaded );
+ $this->register_plugin_install_script();
+
+ $this->sut->handle_admin_enqueue_scripts();
+
+ $this->assertSame( $expect_enqueued, wp_script_is( 'plugin-install', 'enqueued' ) );
+ $this->assertSame( $expect_enqueued, wp_script_is( 'thickbox', 'enqueued' ) );
+ }
+
+ /**
+ * @testdox Should not load the plugin details modal scripts when the notice doesn't show.
+ */
+ public function test_modal_scripts_not_enqueued_when_notice_hidden(): void {
+ $this->set_up_removed_notice_conditions( false );
+ update_option( 'woocommerce_feature_mcp_integration_enabled', 'no' );
+ $this->register_plugin_install_script();
+
+ $this->sut->handle_admin_enqueue_scripts();
+
+ $this->assertFalse( wp_script_is( 'plugin-install', 'enqueued' ) );
+ }
+
+ /**
+ * @testdox Should link to activate the MCP Adapter plugin, without the modal scripts, when it's installed but inactive.
+ */
+ public function test_removed_notice_links_to_activate_installed_adapter(): void {
+ $this->set_up_removed_notice_conditions( false, true );
+ $this->register_plugin_install_script();
+
+ $output = $this->get_admin_notices_output();
+ $this->sut->handle_admin_enqueue_scripts();
+
+ $this->assertStringContainsString( 'Activate MCP Adapter', $output );
+ $this->assertStringContainsString( 'action=activate&plugin=mcp-adapter%2Fmcp-adapter.php', $output );
+ $this->assertStringContainsString( '_wpnonce=', $output, 'The activation link should carry a nonce' );
+ $this->assertStringNotContainsString( 'Install MCP Adapter', $output );
+ $this->assertFalse( wp_script_is( 'plugin-install', 'enqueued' ), 'The modal scripts are only needed for the install link' );
+ }
+
+ /**
+ * @testdox Should delete the old feature option when the MCP Adapter plugin is activated from the notice's link.
+ */
+ public function test_activating_adapter_from_notice_deletes_feature_option(): void {
+ $this->set_up_removed_notice_conditions( false, true );
+ $this->assertStringContainsString( 'wc-mcp-removal-notice=1', $this->get_admin_notices_output(), 'The activation link should identify the notice as its source' );
+
+ $this->simulate_adapter_activation( true );
+
+ $this->assertFalse( get_option( 'woocommerce_feature_mcp_integration_enabled' ), 'The old feature option should be deleted' );
+ $this->assertSame( '', $this->get_admin_notices_output() );
+ }
+
+ /**
+ * @testdox Should keep the old feature option when the MCP Adapter plugin is activated outside the notice.
+ */
+ public function test_activating_adapter_outside_notice_keeps_feature_option(): void {
+ $this->set_up_removed_notice_conditions( false, true );
+
+ $this->simulate_adapter_activation( false );
+
+ $this->assertSame( 'yes', get_option( 'woocommerce_feature_mcp_integration_enabled' ) );
+ }
+
+ /**
+ * @testdox Should keep the old feature option when another plugin is activated from a link with the notice argument.
+ */
+ public function test_activating_other_plugin_keeps_feature_option(): void {
+ $this->set_up_removed_notice_conditions( false, true );
+ $_GET['wc-mcp-removal-notice'] = '1';
+
+ try {
+ $this->sut->handle_activated_plugin( 'hello.php' );
+ } finally {
+ unset( $_GET['wc-mcp-removal-notice'] );
+ }
+
+ $this->assertSame( 'yes', get_option( 'woocommerce_feature_mcp_integration_enabled' ) );
+ }
+
+ /**
+ * @testdox Should delete the old feature option for the whole store when an admin dismisses the notice.
+ */
+ public function test_dismissing_notice_deletes_feature_option(): void {
+ $this->set_up_removed_notice_conditions( false );
+ $_GET['wc-hide-notice'] = McpEndpointRemoval::REMOVED_NOTICE;
+ $_GET['_wc_notice_nonce'] = wp_create_nonce( 'woocommerce_hide_notices_nonce' );
+
+ try {
+ \WC_Admin_Notices::hide_notices();
+ } finally {
+ unset( $_GET['wc-hide-notice'], $_GET['_wc_notice_nonce'] );
+ }
+
+ $this->assertFalse( get_option( 'woocommerce_feature_mcp_integration_enabled' ), 'The old feature option should be deleted' );
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
+ $this->assertSame( '', $this->get_admin_notices_output(), 'Other admins should no longer see the notice' );
+ }
+
+ /**
+ * @testdox Should not show the removal notice when WooCommerce MCP wasn't enabled.
+ */
+ public function test_removed_notice_hidden_when_feature_was_not_enabled(): void {
+ $this->set_up_removed_notice_conditions( false );
+ update_option( 'woocommerce_feature_mcp_integration_enabled', 'no' );
+
+ $this->assertSame( '', $this->get_admin_notices_output() );
+ }
+
+ /**
+ * @testdox Should not show the removal notice after the user dismisses it.
+ */
+ public function test_removed_notice_hidden_when_dismissed(): void {
+ $this->set_up_removed_notice_conditions( false );
+ update_user_meta( get_current_user_id(), 'dismissed_' . McpEndpointRemoval::REMOVED_NOTICE . '_notice', true );
+
+ $this->assertSame( '', $this->get_admin_notices_output() );
+ }
+
+ /**
+ * @testdox Should not show the removal notice to users who can't manage WooCommerce.
+ */
+ public function test_removed_notice_hidden_for_users_without_capability(): void {
+ $this->set_up_removed_notice_conditions( false );
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'customer' ) ) );
+
+ $this->assertSame( '', $this->get_admin_notices_output() );
+ }
+
+ /**
+ * Log in as an administrator, mark WooCommerce MCP as previously enabled, and control MCP adapter detection.
+ *
+ * @param bool $adapter_loaded Whether an MCP adapter should appear to be loaded.
+ * @param bool $plugin_installed Whether the MCP Adapter plugin should appear to be installed.
+ */
+ private function set_up_removed_notice_conditions( bool $adapter_loaded, bool $plugin_installed = false ): void {
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
+ update_option( 'woocommerce_feature_mcp_integration_enabled', 'yes' );
+ $this->register_legacy_proxy_function_mocks(
+ array(
+ 'class_exists' => static function ( $class_name ) use ( $adapter_loaded ) {
+ return 'WP\\MCP\\Core\\McpAdapter' === $class_name ? $adapter_loaded : class_exists( $class_name );
+ },
+ 'file_exists' => static function ( $path ) use ( $plugin_installed ) {
+ return WP_PLUGIN_DIR . '/mcp-adapter/mcp-adapter.php' === $path ? $plugin_installed : file_exists( $path );
+ },
+ )
+ );
+ }
+
+ /**
+ * Fire activated_plugin for the MCP Adapter plugin, as core does during activation.
+ *
+ * @param bool $from_notice Whether the activation request came from the notice's link.
+ */
+ private function simulate_adapter_activation( bool $from_notice ): void {
+ if ( $from_notice ) {
+ $_GET['wc-mcp-removal-notice'] = '1';
+ }
+
+ try {
+ $this->sut->handle_activated_plugin( 'mcp-adapter/mcp-adapter.php' );
+ } finally {
+ unset( $_GET['wc-mcp-removal-notice'] );
+ }
+ }
+
+ /**
+ * Register the plugin-install script, which core only registers in wp-admin.
+ */
+ private function register_plugin_install_script(): void {
+ if ( ! wp_script_is( 'plugin-install', 'registered' ) ) {
+ wp_register_script( 'plugin-install', '/wp-admin/js/plugin-install.js', array( 'jquery', 'jquery-ui-core', 'thickbox' ), '1.0.0', true );
+ }
+ }
+
+ /**
+ * Get the output of the admin notices callback.
+ *
+ * @return string
+ */
+ private function get_admin_notices_output(): string {
+ ob_start();
+ $this->sut->handle_admin_notices();
+ return (string) ob_get_clean();
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/MCP/RemovedMcpClassesTest.php b/plugins/woocommerce/tests/php/src/Internal/MCP/RemovedMcpClassesTest.php
new file mode 100644
index 00000000000..a71dd49c5b5
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/MCP/RemovedMcpClassesTest.php
@@ -0,0 +1,87 @@
+<?php
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\MCP;
+
+use Automattic\WooCommerce\Internal\MCP\MCPAdapterProvider;
+use Automattic\WooCommerce\Internal\MCP\Transport\WooCommerceRestTransport;
+use WC_Unit_Test_Case;
+use WP_Error;
+use WP_REST_Request;
+
+/**
+ * Tests for the polyfills left in place of the removed WooCommerce MCP endpoint classes.
+ */
+class RemovedMcpClassesTest extends WC_Unit_Test_Case {
+
+ /**
+ * @testdox Should report incorrect usage and return a safe default when a removed method is called.
+ *
+ * @dataProvider removed_method_provider
+ *
+ * @param string $method The removed method, as reported to _doing_it_wrong().
+ * @param callable $callback Calls the removed method and returns its result.
+ * @param mixed $expected The expected safe default.
+ */
+ public function test_removed_methods_report_incorrect_usage( string $method, callable $callback, $expected ): void {
+ $this->setExpectedIncorrectUsage( $method );
+
+ $this->assertSame( $expected, $callback() );
+ }
+
+ /**
+ * Data provider for removed methods.
+ *
+ * @return array<string, array{string, callable, mixed}>
+ */
+ public function removed_method_provider(): array {
+ return array(
+ 'MCPAdapterProvider::is_mcp_request' => array(
+ MCPAdapterProvider::class . '::is_mcp_request',
+ static fn() => MCPAdapterProvider::is_mcp_request(),
+ false,
+ ),
+ 'WooCommerceRestTransport::get_current_user_permissions' => array(
+ WooCommerceRestTransport::class . '::get_current_user_permissions',
+ static fn() => WooCommerceRestTransport::get_current_user_permissions(),
+ null,
+ ),
+ );
+ }
+
+ /**
+ * @testdox Should keep MCPAdapterProvider instance methods callable without initializing anything.
+ */
+ public function test_provider_instance_methods_do_nothing(): void {
+ $this->setExpectedIncorrectUsage( MCPAdapterProvider::class . '::__construct' );
+ $this->setExpectedIncorrectUsage( MCPAdapterProvider::class . '::maybe_initialize' );
+ $this->setExpectedIncorrectUsage( MCPAdapterProvider::class . '::initialize_mcp_server' );
+ $this->setExpectedIncorrectUsage( MCPAdapterProvider::class . '::is_initialized' );
+
+ $provider = new MCPAdapterProvider();
+ $provider->maybe_initialize();
+ $provider->initialize_mcp_server( new \stdClass() );
+
+ $this->assertFalse( $provider->is_initialized() );
+ $this->assertSame( 'woocommerce', MCPAdapterProvider::MCP_NAMESPACE );
+ $this->assertSame( 'mcp', MCPAdapterProvider::MCP_ROUTE );
+ }
+
+ /**
+ * @testdox Should keep WooCommerceRestTransport instance methods callable while denying access.
+ */
+ public function test_transport_instance_methods_deny_access(): void {
+ $this->setExpectedIncorrectUsage( WooCommerceRestTransport::class . '::__construct' );
+ $this->setExpectedIncorrectUsage( WooCommerceRestTransport::class . '::check_permission' );
+ $this->setExpectedIncorrectUsage( WooCommerceRestTransport::class . '::validate_request' );
+ $this->setExpectedIncorrectUsage( WooCommerceRestTransport::class . '::check_ability_permission' );
+
+ $transport = new WooCommerceRestTransport( null );
+ $request = new WP_REST_Request( 'POST', '/woocommerce/mcp' );
+
+ $this->assertInstanceOf( WP_Error::class, $transport->check_permission( $request ) );
+ $this->assertInstanceOf( WP_Error::class, $transport->validate_request( $request ) );
+ $this->assertTrue( $transport->check_ability_permission( true, 'GET', new \stdClass() ), 'The permission value should pass through unchanged' );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/MCP/WooCommerceRestTransportTest.php b/plugins/woocommerce/tests/php/src/Internal/MCP/WooCommerceRestTransportTest.php
deleted file mode 100644
index 887efcfc564..00000000000
--- a/plugins/woocommerce/tests/php/src/Internal/MCP/WooCommerceRestTransportTest.php
+++ /dev/null
@@ -1,384 +0,0 @@
-<?php
-/**
- * WooCommerceRestTransportTest class file.
- */
-
-declare( strict_types=1 );
-
-namespace Automattic\WooCommerce\Tests\Internal\MCP;
-
-use Automattic\WooCommerce\Internal\MCP\Transport\WooCommerceRestTransport;
-
-/**
- * Tests for the WooCommerceRestTransport class.
- */
-class WooCommerceRestTransportTest extends \WC_Unit_Test_Case {
-
- /**
- * The system under test.
- *
- * @var WooCommerceRestTransport
- */
- private $sut;
-
- /**
- * Set up before each test.
- */
- public function setUp(): void {
- parent::setUp();
-
- // Bootstrap the MCP Adapter for tests (required for transport context).
- if ( ! class_exists( 'WP\\MCP\\Transport\\Infrastructure\\McpTransportContext' ) ) {
- $mcp_bootstrap = WP_PLUGIN_DIR . '/woocommerce/vendor/wordpress/mcp-adapter/includes/Autoloader.php';
- if ( file_exists( $mcp_bootstrap ) ) {
- require_once $mcp_bootstrap;
- // Initialize the autoloader.
- if ( class_exists( 'WP\\MCP\\Autoloader' ) ) {
- \WP\MCP\Autoloader::autoload();
- }
- }
- }
-
- // Create a mock transport context since we're not testing the full transport integration.
- $mock_context = $this->createMock( 'WP\MCP\Transport\Infrastructure\McpTransportContext' );
- $this->sut = new WooCommerceRestTransport( $mock_context );
- }
-
- /**
- * Clean up after each test.
- */
- public function tearDown(): void {
- // Clean up any global state changes.
- wp_set_current_user( 0 );
-
- // Remove any test filters.
- remove_all_filters( 'woocommerce_mcp_allow_insecure_transport' );
-
- parent::tearDown();
- }
-
- /**
- * Test HTTPS requirement enforcement.
- */
- public function test_validate_request_requires_https_by_default() {
- $request = new \WP_REST_Request();
-
- // Mock non-SSL environment.
- add_filter( 'is_ssl', '__return_false' );
-
- $result = $this->sut->validate_request( $request );
-
- $this->assertInstanceOf( \WP_Error::class, $result, 'Should return WP_Error for non-HTTPS requests' );
- $this->assertEquals( 'insecure_transport', $result->get_error_code(), 'Should have insecure transport error code' );
-
- remove_filter( 'is_ssl', '__return_false' );
- }
-
- /**
- * Test HTTPS requirement can be bypassed with filter.
- */
- public function test_validate_request_allows_insecure_with_filter() {
- $request = new \WP_REST_Request();
- $request->set_header( 'X-MCP-API-Key', 'valid_key:valid_secret' );
-
- // Mock non-SSL environment but allow insecure transport.
- add_filter( 'is_ssl', '__return_false' );
- add_filter( 'woocommerce_mcp_allow_insecure_transport', '__return_true' );
-
- // Mock valid API key authentication.
- global $wpdb;
- $wpdb->insert(
- $wpdb->prefix . 'woocommerce_api_keys',
- array(
- 'user_id' => 1,
- 'description' => 'Test Key',
- 'permissions' => 'read_write',
- 'consumer_key' => wc_api_hash( 'valid_key' ),
- 'consumer_secret' => 'valid_secret',
- 'truncated_key' => 'ck_test',
- )
- );
-
- $result = $this->sut->validate_request( $request );
-
- $this->assertTrue( $result, 'Should allow insecure transport when filter is enabled' );
-
- // Cleanup.
- $wpdb->delete( $wpdb->prefix . 'woocommerce_api_keys', array( 'consumer_key' => wc_api_hash( 'valid_key' ) ) );
- remove_filter( 'is_ssl', '__return_false' );
- }
-
- /**
- * Test missing API key header.
- */
- public function test_validate_request_requires_api_key_header() {
- $request = new \WP_REST_Request();
-
- // Allow insecure transport for testing - we're testing API key validation logic,
- // not HTTPS enforcement. We trust WordPress's is_ssl() function works correctly.
- add_filter( 'woocommerce_mcp_allow_insecure_transport', '__return_true' );
-
- $result = $this->sut->validate_request( $request );
-
- $this->assertInstanceOf( \WP_Error::class, $result, 'Should return WP_Error for missing API key' );
- $this->assertEquals( 'missing_api_key', $result->get_error_code(), 'Should have missing API key error code' );
- }
-
- /**
- * Test invalid API key format.
- */
- public function test_validate_request_validates_api_key_format() {
- $request = new \WP_REST_Request();
- $request->set_header( 'X-MCP-API-Key', 'invalid_format_without_colon' );
-
- // Allow insecure transport for testing - we're testing API key format validation,
- // not HTTPS enforcement. We trust WordPress's is_ssl() function works correctly.
- add_filter( 'woocommerce_mcp_allow_insecure_transport', '__return_true' );
-
- $result = $this->sut->validate_request( $request );
-
- $this->assertInstanceOf( \WP_Error::class, $result, 'Should return WP_Error for invalid API key format' );
- $this->assertEquals( 'invalid_api_key', $result->get_error_code(), 'Should have invalid API key error code' );
- }
-
- /**
- * Test invalid consumer key.
- */
- public function test_validate_request_rejects_invalid_consumer_key() {
- $request = new \WP_REST_Request();
- $request->set_header( 'X-MCP-API-Key', 'invalid_key:some_secret' );
-
- // Allow insecure transport for testing - we're testing database authentication logic,
- // not HTTPS enforcement. We trust WordPress's is_ssl() function works correctly.
- add_filter( 'woocommerce_mcp_allow_insecure_transport', '__return_true' );
-
- $result = $this->sut->validate_request( $request );
-
- $this->assertInstanceOf( \WP_Error::class, $result, 'Should return WP_Error for invalid consumer key' );
- $this->assertEquals( 'authentication_failed', $result->get_error_code(), 'Should have authentication failed error code' );
- }
-
- /**
- * Test invalid consumer secret.
- */
- public function test_validate_request_rejects_invalid_consumer_secret() {
- $request = new \WP_REST_Request();
- $request->set_header( 'X-MCP-API-Key', 'valid_key:invalid_secret' );
-
- // Allow insecure transport for testing - we're testing hash_equals secret validation,
- // not HTTPS enforcement. We trust WordPress's is_ssl() function works correctly.
- add_filter( 'woocommerce_mcp_allow_insecure_transport', '__return_true' );
-
- // Insert valid API key with different secret.
- global $wpdb;
- $wpdb->insert(
- $wpdb->prefix . 'woocommerce_api_keys',
- array(
- 'user_id' => 1,
- 'description' => 'Test Key',
- 'permissions' => 'read_write',
- 'consumer_key' => wc_api_hash( 'valid_key' ),
- 'consumer_secret' => 'correct_secret',
- 'truncated_key' => 'ck_test',
- )
- );
-
- $result = $this->sut->validate_request( $request );
-
- $this->assertInstanceOf( \WP_Error::class, $result, 'Should return WP_Error for invalid consumer secret' );
- $this->assertEquals( 'authentication_failed', $result->get_error_code(), 'Should have authentication failed error code' );
-
- // Cleanup.
- $wpdb->delete( $wpdb->prefix . 'woocommerce_api_keys', array( 'consumer_key' => wc_api_hash( 'valid_key' ) ) );
- }
-
- /**
- * Test valid authentication.
- */
- public function test_validate_request_accepts_valid_credentials() {
- $request = new \WP_REST_Request();
- $request->set_header( 'X-MCP-API-Key', 'valid_key:valid_secret' );
-
- // Allow insecure transport for testing - we're testing successful authentication flow,
- // not HTTPS enforcement. We trust WordPress's is_ssl() function works correctly.
- add_filter( 'woocommerce_mcp_allow_insecure_transport', '__return_true' );
-
- // Create a test user.
- $user_id = $this->factory->user->create();
-
- // Insert valid API key.
- global $wpdb;
- $wpdb->insert(
- $wpdb->prefix . 'woocommerce_api_keys',
- array(
- 'user_id' => $user_id,
- 'description' => 'Test Key',
- 'permissions' => 'read_write',
- 'consumer_key' => wc_api_hash( 'valid_key' ),
- 'consumer_secret' => 'valid_secret',
- 'truncated_key' => 'ck_test',
- )
- );
-
- $result = $this->sut->validate_request( $request );
-
- $this->assertTrue( $result, 'Should accept valid credentials' );
-
- // Verify user context was set.
- $this->assertEquals( $user_id, get_current_user_id(), 'Should set current user context' );
-
- // Cleanup.
- $wpdb->delete( $wpdb->prefix . 'woocommerce_api_keys', array( 'consumer_key' => wc_api_hash( 'valid_key' ) ) );
- }
-
- /**
- * Test user existence validation.
- */
- public function test_validate_request_validates_user_exists() {
- $request = new \WP_REST_Request();
- $request->set_header( 'X-MCP-API-Key', 'valid_key:valid_secret' );
-
- // Allow insecure transport for testing - we're testing user existence validation,
- // not HTTPS enforcement. We trust WordPress's is_ssl() function works correctly.
- add_filter( 'woocommerce_mcp_allow_insecure_transport', '__return_true' );
-
- // Insert API key with non-existent user.
- global $wpdb;
- $wpdb->insert(
- $wpdb->prefix . 'woocommerce_api_keys',
- array(
- 'user_id' => 99999, // Non-existent user ID.
- 'description' => 'Test Key',
- 'permissions' => 'read_write',
- 'consumer_key' => wc_api_hash( 'valid_key' ),
- 'consumer_secret' => 'valid_secret',
- 'truncated_key' => 'ck_test',
- )
- );
-
- $result = $this->sut->validate_request( $request );
-
- $this->assertInstanceOf( \WP_Error::class, $result, 'Should return WP_Error for non-existent user' );
- $this->assertEquals( 'mcp_user_not_found', $result->get_error_code(), 'Should have user not found error code' );
-
- // Cleanup.
- $wpdb->delete( $wpdb->prefix . 'woocommerce_api_keys', array( 'consumer_key' => wc_api_hash( 'valid_key' ) ) );
- }
-
- /**
- * Test permission checking for GET requests.
- */
- public function test_check_ability_permission_allows_get_with_read_permission() {
- // Set up MCP context with read permissions.
- $reflection = new \ReflectionClass( WooCommerceRestTransport::class );
- $property = $reflection->getProperty( 'current_mcp_permissions' );
- $property->setAccessible( true );
- $property->setValue( 'read' );
-
- $result = $this->sut->check_ability_permission( false, 'GET', new \stdClass() );
-
- $this->assertTrue( $result, 'Should allow GET requests with read permissions' );
-
- // Cleanup.
- $property->setValue( null );
- }
-
- /**
- * Test permission checking for POST requests.
- */
- public function test_check_ability_permission_denies_post_with_read_permission() {
- // Set up MCP context with read permissions.
- $reflection = new \ReflectionClass( WooCommerceRestTransport::class );
- $property = $reflection->getProperty( 'current_mcp_permissions' );
- $property->setAccessible( true );
- $property->setValue( 'read' );
-
- $result = $this->sut->check_ability_permission( false, 'POST', new \stdClass() );
-
- $this->assertFalse( $result, 'Should deny POST requests with read-only permissions' );
-
- // Cleanup.
- $property->setValue( null );
- }
-
- /**
- * Test permission checking for POST requests with write permission.
- */
- public function test_check_ability_permission_allows_post_with_write_permission() {
- // Set up MCP context with write permissions.
- $reflection = new \ReflectionClass( WooCommerceRestTransport::class );
- $property = $reflection->getProperty( 'current_mcp_permissions' );
- $property->setAccessible( true );
- $property->setValue( 'write' );
-
- $result = $this->sut->check_ability_permission( false, 'POST', new \stdClass() );
-
- $this->assertTrue( $result, 'Should allow POST requests with write permissions' );
-
- // Cleanup.
- $property->setValue( null );
- }
-
- /**
- * Test permission checking for read_write permission.
- */
- public function test_check_ability_permission_allows_all_with_read_write_permission() {
- // Set up MCP context with read_write permissions.
- $reflection = new \ReflectionClass( WooCommerceRestTransport::class );
- $property = $reflection->getProperty( 'current_mcp_permissions' );
- $property->setAccessible( true );
- $property->setValue( 'read_write' );
-
- $get_result = $this->sut->check_ability_permission( false, 'GET', new \stdClass() );
- $post_result = $this->sut->check_ability_permission( false, 'POST', new \stdClass() );
- $delete_result = $this->sut->check_ability_permission( false, 'DELETE', new \stdClass() );
-
- $this->assertTrue( $get_result, 'Should allow GET requests with read_write permissions' );
- $this->assertTrue( $post_result, 'Should allow POST requests with read_write permissions' );
- $this->assertTrue( $delete_result, 'Should allow DELETE requests with read_write permissions' );
-
- // Cleanup.
- $property->setValue( null );
- }
-
- /**
- * Test permission checking without MCP context.
- */
- public function test_check_ability_permission_preserves_original_without_mcp_context() {
- // No MCP context set (permissions = null).
- $result = $this->sut->check_ability_permission( true, 'GET', new \stdClass() );
-
- $this->assertTrue( $result, 'Should preserve original permission when no MCP context' );
-
- $result = $this->sut->check_ability_permission( false, 'GET', new \stdClass() );
-
- $this->assertFalse( $result, 'Should preserve original permission when no MCP context' );
- }
-
- /**
- * Test get current user permissions.
- */
- public function test_get_current_user_permissions_returns_null_without_context() {
- $permissions = WooCommerceRestTransport::get_current_user_permissions();
-
- $this->assertNull( $permissions, 'Should return null when no MCP context' );
- }
-
- /**
- * Test get current user permissions returns set value.
- */
- public function test_get_current_user_permissions_returns_set_value() {
- // Set permissions via reflection (simulating authentication).
- $reflection = new \ReflectionClass( WooCommerceRestTransport::class );
- $property = $reflection->getProperty( 'current_mcp_permissions' );
- $property->setAccessible( true );
- $property->setValue( 'read_write' );
-
- $permissions = WooCommerceRestTransport::get_current_user_permissions();
-
- $this->assertEquals( 'read_write', $permissions, 'Should return set permissions' );
-
- // Cleanup.
- $property->setValue( null );
- }
-}