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',