Commit 7f5cb575c8b for woocommerce
commit 7f5cb575c8b90072fb96a51e75c5bac1cbfcaa22
Author: Luis Felipe Zaguini <26530524+zaguiini@users.noreply.github.com>
Date: Tue Nov 3 10:28:53 2026 -0300
Take the slug and status query filters as lists only (#70559)
* Take the product-terms-query slug filter as a list only
The input schema declared `slug` as a string or an array. A client that
maps tool schemas onto one type per field, such as Gemini function
declarations, may reject the tool or drop the constraint, and the MCP
Inspector warns on it. A list of one slug covers the single case.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Test that an empty slug list applies no slug filter
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Take the orders-query status filter as a list only
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Cast the parsed statuses to strings before cleaning them
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Test that every Core ability input field has one type
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Parse a comma-separated slug string in product-terms-query
WordPress turns a comma-separated string into a list only on the REST
route. The MCP adapter calls execute() directly, so "alpha,beta" matched
no term.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Align the term query arguments after the slug block
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/wooai-product-terms-slug-list b/plugins/woocommerce/changelog/wooai-product-terms-slug-list
new file mode 100644
index 00000000000..35716e8416a
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooai-product-terms-slug-list
@@ -0,0 +1,4 @@
+Significance: patch
+Type: update
+
+The product-terms-query slug filter and the orders-query status filter take a list only, so each input schema has one type.
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/OrdersQuery.php b/plugins/woocommerce/src/Internal/Abilities/Domain/OrdersQuery.php
index 395d4dff2da..28a9aa2e4f3 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/OrdersQuery.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/OrdersQuery.php
@@ -117,12 +117,7 @@ class OrdersQuery extends AbstractDomainAbility implements AbilityDefinition {
}
}
- $status = $input['status'] ?? array();
- if ( is_string( $status ) && '' !== $status ) {
- $args['status'] = array( wc_clean( $status ) );
- } elseif ( is_array( $status ) && ! empty( $status ) ) {
- $args['status'] = array_map( 'wc_clean', array_filter( array_map( 'strval', $status ), static fn ( string $slug ): bool => '' !== $slug ) );
- }
+ $args['status'] = array_map( 'wc_clean', array_map( 'strval', wp_parse_list( $input['status'] ?? array() ) ) );
if ( empty( $args['status'] ) ) {
$args['status'] = 'shop_order' === $args['type'] ? self::get_allowed_order_status_slugs() : self::get_query_order_status_slugs();
@@ -252,19 +247,11 @@ class OrdersQuery extends AbstractDomainAbility implements AbilityDefinition {
'default' => 'shop_order',
),
'status' => array(
- 'description' => __( 'Filter by one order status slug, or a list of slugs, without the wc- prefix. Custom order types may add their own statuses.', 'woocommerce' ),
- 'anyOf' => array(
- array(
- 'type' => 'string',
- 'enum' => self::get_query_order_status_slugs(),
- ),
- array(
- 'type' => 'array',
- 'items' => array(
- 'type' => 'string',
- 'enum' => self::get_query_order_status_slugs(),
- ),
- ),
+ 'description' => __( 'Limit results to these order status slugs, without the wc- prefix. Send one status as a list of one. An empty list applies no status filter. Custom order types may add their own statuses.', 'woocommerce' ),
+ 'type' => 'array',
+ 'items' => array(
+ 'type' => 'string',
+ 'enum' => self::get_query_order_status_slugs(),
),
),
'customer_id' => array(
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductTermsQuery.php b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductTermsQuery.php
index 0aa50f7e8f6..70adad37292 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/ProductTermsQuery.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/ProductTermsQuery.php
@@ -96,8 +96,11 @@ class ProductTermsQuery extends AbstractDomainAbility implements AbilityDefiniti
array_intersect_key( $input, array_flip( array( 'taxonomy', 'include', 'exclude', 'search', 'slug', 'parent', 'hide_empty', 'orderby', 'order' ) ) )
);
$args['hide_empty'] = wp_validate_boolean( $input['hide_empty'] ?? false );
- $args['number'] = $per_page;
- $args['offset'] = $input['offset'] ?? ( $page - 1 ) * $per_page;
+ if ( isset( $input['slug'] ) ) {
+ $args['slug'] = wp_parse_slug_list( $input['slug'] );
+ }
+ $args['number'] = $per_page;
+ $args['offset'] = $input['offset'] ?? ( $page - 1 ) * $per_page;
if ( ! empty( $input['product'] ) ) {
$args['object_ids'] = array( $input['product'] );
}
@@ -215,8 +218,8 @@ class ProductTermsQuery extends AbstractDomainAbility implements AbilityDefiniti
),
'search' => array( 'type' => 'string' ),
'slug' => array(
- 'description' => __( 'Limit results to these slugs. An empty string or list applies no slug filter.', 'woocommerce' ),
- 'type' => array( 'string', 'array' ),
+ 'description' => __( 'Limit results to these slugs. Send one slug as a list of one. An empty list applies no slug filter.', 'woocommerce' ),
+ 'type' => 'array',
'items' => array( 'type' => 'string' ),
),
'parent' => array(
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
index a6e6bab0ffa..2c58d839b29 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/AbilitiesLoaderTest.php
@@ -207,6 +207,57 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
}
}
+ /**
+ * @testdox Should give every input field of a Core ability one type, so clients that accept one type per field can use the tools.
+ */
+ public function test_core_ability_inputs_have_one_type_per_field(): void {
+ $abilities = array_filter(
+ wp_get_abilities(),
+ static fn( \WP_Ability $ability ): bool => 0 === strpos( $ability->get_name(), 'woocommerce/' )
+ );
+ $this->assertNotEmpty( $abilities );
+
+ $problems = array();
+ foreach ( $abilities as $ability ) {
+ $this->collect_mixed_types( $ability->get_input_schema(), $ability->get_name(), $problems );
+ }
+
+ $this->assertSame( array(), $problems );
+ }
+
+ /**
+ * Collect the paths of a schema where a field takes a type list, or one value or a list of it.
+ *
+ * @param mixed $schema Schema to walk.
+ * @param string $path Path of the schema.
+ * @param string[] $problems Paths found so far.
+ */
+ private function collect_mixed_types( $schema, string $path, array &$problems ): void {
+ if ( ! is_array( $schema ) ) {
+ return;
+ }
+ if ( is_array( $schema['type'] ?? null ) ) {
+ $problems[] = $path . ' declares the type list ' . implode( '|', $schema['type'] );
+ }
+ foreach ( array( 'anyOf', 'oneOf' ) as $keyword ) {
+ $types = array_column( $schema[ $keyword ] ?? array(), 'type' );
+ if ( in_array( 'array', $types, true ) && count( array_unique( $types ) ) > 1 ) {
+ $problems[] = $path . ' takes one value or a list in ' . $keyword;
+ }
+ }
+ foreach ( array( 'properties', 'patternProperties' ) as $keyword ) {
+ foreach ( $schema[ $keyword ] ?? array() as $name => $child ) {
+ $this->collect_mixed_types( $child, $path . '.' . $name, $problems );
+ }
+ }
+ foreach ( array( 'items', 'additionalProperties' ) as $keyword ) {
+ $this->collect_mixed_types( $schema[ $keyword ] ?? null, $path . '.' . $keyword, $problems );
+ }
+ foreach ( array_merge( $schema['anyOf'] ?? array(), $schema['oneOf'] ?? array() ) as $index => $branch ) {
+ $this->collect_mixed_types( $branch, $path . '.' . $index, $problems );
+ }
+ }
+
/**
* @testdox Should mark write abilities as destructive and queries as readonly/idempotent.
*/
@@ -403,7 +454,7 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
$query_schema = wp_get_ability( 'woocommerce/orders-query' )->get_input_schema();
$update_schema = wp_get_ability( 'woocommerce/order-update-status' )->get_input_schema();
- $this->assertNotContains( 'checkout-draft', $query_schema['properties']['status']['anyOf'][0]['enum'] ?? array() );
+ $this->assertNotContains( 'checkout-draft', $query_schema['properties']['status']['items']['enum'] );
$this->assertNotContains( 'checkout-draft', $update_schema['properties']['status']['enum'] ?? array() );
}
@@ -456,7 +507,8 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
$output = $ability->get_output_schema()['properties']['orders']['items']['properties'];
$this->assertContains( 'shop_test_contract', $input['type']['enum'] );
- $this->assertContains( 'test-active', $input['status']['anyOf'][0]['enum'] );
+ $this->assertSame( 'array', $input['status']['type'] );
+ $this->assertContains( 'test-active', $input['status']['items']['enum'] );
$this->assertContains( 'test-active', $output['status']['enum'] );
$query_args = $this->capture_order_query_args(
@@ -524,7 +576,7 @@ class AbilitiesLoaderTest extends \WC_Unit_Test_Case {
$input = $ability->get_input_schema()['properties'];
$output = $ability->get_output_schema()['properties']['orders']['items']['properties'];
- $this->assertNotContains( 'test-hidden', $input['status']['anyOf'][0]['enum'] );
+ $this->assertNotContains( 'test-hidden', $input['status']['items']['enum'] );
$this->assertContains( 'test-hidden', $output['status']['enum'] );
$query_args = $this->capture_order_query_args( array( 'type' => 'shop_test_contract' ) );
diff --git a/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/ProductTermsQueryTest.php b/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/ProductTermsQueryTest.php
index e9ffea889e4..a6d11c3ac99 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/ProductTermsQueryTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Abilities/Domain/ProductTermsQueryTest.php
@@ -241,6 +241,23 @@ class ProductTermsQueryTest extends \WC_Unit_Test_Case {
$this->assertSame( $expected_count, $result['total'] );
}
+ /**
+ * @testdox Should publish the slug filter as a list, so the input schema has one type.
+ */
+ public function test_slug_filter_is_a_list(): void {
+ $ability = new \WP_Ability( ProductTermsQuery::get_name(), ProductTermsQuery::get_registration_args() );
+
+ $this->assertSame( 'array', $ability->get_input_schema()['properties']['slug']['type'] );
+ $this->assertTrue(
+ $ability->validate_input(
+ array(
+ 'taxonomy' => 'product_tag',
+ 'slug' => array( 'beta' ),
+ )
+ )
+ );
+ }
+
/**
* @testdox Should support product, slug, exclude and offset filters.
*/
@@ -265,10 +282,18 @@ class ProductTermsQueryTest extends \WC_Unit_Test_Case {
array(
'taxonomy' => 'product_tag',
'product' => $product->get_id(),
- 'slug' => 'beta',
+ 'slug' => array( 'beta' ),
)
);
$this->assertSame( array( $second ), array_column( $result['terms'], 'id' ) );
+ $result = ProductTermsQuery::execute(
+ array(
+ 'taxonomy' => 'product_tag',
+ 'product' => $product->get_id(),
+ 'slug' => array(),
+ )
+ );
+ $this->assertSame( array( $first, $second ), array_column( $result['terms'], 'id' ) );
$result = ProductTermsQuery::execute(
array(
'taxonomy' => 'product_tag',
@@ -277,6 +302,13 @@ class ProductTermsQueryTest extends \WC_Unit_Test_Case {
)
);
$this->assertSame( array( $second ), array_column( $result['terms'], 'id' ) );
+ $result = ProductTermsQuery::execute(
+ array(
+ 'taxonomy' => 'product_tag',
+ 'slug' => 'alpha,beta',
+ )
+ );
+ $this->assertSame( array( $first, $second ), array_column( $result['terms'], 'id' ) );
$result = ProductTermsQuery::execute(
array(
'taxonomy' => 'product_tag',