Commit e770c2aa3ac for woocommerce

commit e770c2aa3ac7238853d15e58a2609c44fe28d1dd
Author: Sergey Mitroshin <sergeymitr@gmail.com>
Date:   Thu Oct 15 15:43:59 2026 -0400

    UCP: Add catalog search, lookup and product endpoints (#69577)

    Adds the UCP (Universal Commerce Protocol) catalog endpoints to WooCommerce Core.

    ---------

    Co-authored-by: Timur Gogolev <timur.gogolev@automattic.com>

diff --git a/plugins/woocommerce/changelog/add-ucp-catalog-routes b/plugins/woocommerce/changelog/add-ucp-catalog-routes
new file mode 100644
index 00000000000..59c6f8aba48
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-ucp-catalog-routes
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add opt-in UCP catalog search, lookup and product endpoints so AI shopping agents can browse the store.
diff --git a/plugins/woocommerce/src/Internal/Features/FeaturesController.php b/plugins/woocommerce/src/Internal/Features/FeaturesController.php
index 6dfb260dfe5..87fdc15f74f 100644
--- a/plugins/woocommerce/src/Internal/Features/FeaturesController.php
+++ b/plugins/woocommerce/src/Internal/Features/FeaturesController.php
@@ -642,6 +642,19 @@ class FeaturesController {
 				'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
 				'is_legacy'                    => false,
 			),
+			'ucp'                                  => array(
+				'name'                         => __( 'Universal Commerce Protocol', 'woocommerce' ),
+				'description'                  => __(
+					'Expose the catalog to AI shopping agents through UCP endpoints.',
+					'woocommerce'
+				),
+				'enabled_by_default'           => false,
+				// Hidden: the flag is set by the integration that drives UCP, not by merchants.
+				'disable_ui'                   => true,
+				'is_experimental'              => true,
+				'skip_compatibility_checks'    => true,
+				'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
+			),
 			'destroy-empty-sessions'               => array(
 				'name'                         => __( 'Clear Customer Sessions When Empty', 'woocommerce' ),
 				'description'                  => __(
diff --git a/plugins/woocommerce/src/Internal/ProductFeed/Mapping/UcpProductMapper.php b/plugins/woocommerce/src/Internal/ProductFeed/Mapping/UcpProductMapper.php
new file mode 100644
index 00000000000..4686fb24d64
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/ProductFeed/Mapping/UcpProductMapper.php
@@ -0,0 +1,937 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\ProductFeed\Mapping;
+
+use Automattic\WooCommerce\StoreApi\SchemaController;
+use Automattic\WooCommerce\StoreApi\Schemas\V1\ProductSchema;
+use Automattic\WooCommerce\StoreApi\Schemas\V1\Ucp\UcpProductSchema;
+use Automattic\WooCommerce\StoreApi\StoreApi;
+
+/**
+ * Maps WooCommerce products to the UCP catalog product shape.
+ *
+ * Field values are read from Store API product item responses, so tax display,
+ * password protection, description formatting and attribute labels follow the
+ * storefront. Instances carry per-response state (variant budget, truncation
+ * records), so a caller assembling one response reuses one instance and calls
+ * {@see self::begin_response()} before its first mapping call.
+ *
+ * @since 11.3.0
+ */
+class UcpProductMapper implements ProductShapeMapperInterface {
+	/**
+	 * Maximum number of variants returned for a single product. Each variant is
+	 * a product load; a request naming its variations never hits this cap.
+	 */
+	const MAX_VARIANTS_PER_PRODUCT = 100;
+
+	/**
+	 * Maximum number of variants built across one response. Not absolute:
+	 * `variants` has `minItems: 1`, so the worst case is this budget plus one
+	 * per product on the page.
+	 */
+	const MAX_VARIANTS_PER_RESPONSE = 500;
+
+	/**
+	 * Maximum number of media entries returned per product.
+	 */
+	const MAX_MEDIA_PER_PRODUCT = 10;
+
+	/**
+	 * Maximum number of categories or tags returned per product.
+	 */
+	const MAX_TERMS_PER_PRODUCT = 25;
+
+	/**
+	 * ISO 4217 currency code, or '' to resolve the store currency per call.
+	 *
+	 * @var string
+	 */
+	private $currency;
+
+	/**
+	 * Store API product schema the item responses are read from.
+	 *
+	 * @var ProductSchema|null
+	 */
+	private $schema;
+
+	/**
+	 * Products whose variant list was cut short, keyed by product id.
+	 *
+	 * @var array
+	 */
+	private $truncations = array();
+
+	/**
+	 * Variants still available to build in the response being assembled.
+	 *
+	 * @var int
+	 */
+	private $variant_budget = self::MAX_VARIANTS_PER_RESPONSE;
+
+	/**
+	 * Constructor.
+	 *
+	 * @param string             $currency ISO 4217 currency code; '' uses the store currency.
+	 * @param ProductSchema|null $schema Product schema; null resolves the UCP product schema.
+	 */
+	public function __construct( string $currency = '', ?ProductSchema $schema = null ) {
+		$this->currency = $currency;
+		$this->schema   = $schema;
+	}
+
+	/**
+	 * Start a new response, clearing per-response state.
+	 */
+	public function begin_response(): void {
+		$this->truncations    = array();
+		$this->variant_budget = self::MAX_VARIANTS_PER_RESPONSE;
+	}
+
+	/**
+	 * Take the truncations recorded while building the current response.
+	 *
+	 * Raw data, not messages: only the caller knows the JSONPath to point at.
+	 *
+	 * @return array Keyed by product id, each `{ title, returned, total }`.
+	 */
+	public function take_truncations(): array {
+		$truncations       = $this->truncations;
+		$this->truncations = array();
+
+		return $truncations;
+	}
+
+	/**
+	 * Map a page of products, skipping those with no catalog variant.
+	 *
+	 * @param array $products WooCommerce products.
+	 * @return array
+	 */
+	public function map_products( array $products ): array {
+		$this->begin_response();
+
+		$mapped = array();
+
+		foreach ( $products as $product ) {
+			if ( ! $product instanceof \WC_Product ) {
+				continue;
+			}
+
+			if ( ! $this->has_catalog_variants( $product ) ) {
+				continue;
+			}
+
+			$mapped[] = $this->map_product( $product );
+		}
+
+		return $mapped;
+	}
+
+	/**
+	 * Map a product to the UCP catalog product shape.
+	 *
+	 * @param \WC_Product $product The product to map.
+	 * @param array       $required_variation_ids Variation ids the request named directly, which the
+	 *                                            per-product variant cap must not drop.
+	 * @param bool        $only_required Build only the named variations, because the caller will
+	 *                                   discard everything else.
+	 * @return array The mapped product data.
+	 */
+	public function map_product( \WC_Product $product, array $required_variation_ids = array(), bool $only_required = false ): array {
+		return $this->map_item( $product, $this->item( $product ), $required_variation_ids, $only_required );
+	}
+
+	/**
+	 * Map a product for `POST /catalog/lookup`.
+	 *
+	 * Every returned variant must be justified by a request identifier via the
+	 * required `inputs` metadata. A product-level identifier resolves to all of
+	 * the product's variants as `featured` — server-selected representatives —
+	 * while an identifier naming a variant directly by id resolves
+	 * only to that variant, as `exact`. Variants no identifier resolved to are
+	 * dropped, so a request naming only variations gets only those variations
+	 * back; several identifiers resolving to one variant all appear in its
+	 * `inputs`.
+	 *
+	 * @param \WC_Product $product WooCommerce product.
+	 * @param array       $inputs Identifiers from the lookup request that resolved to this product,
+	 *                            each `{ id, variation_id }` with the variation it named or 0 for
+	 *                            the product itself.
+	 * @return array
+	 */
+	public function map_lookup_product( \WC_Product $product, array $inputs ): array {
+		// A variation named by id must survive the per-product cap.
+		$required      = array();
+		$names_product = false;
+		foreach ( $inputs as $input ) {
+			if ( $input['variation_id'] > 0 ) {
+				$required[] = (int) $input['variation_id'];
+				continue;
+			}
+
+			// A product-level identifier needs the full variant list.
+			$names_product = true;
+		}
+
+		// With only variation-level identifiers, build only those variants: the
+		// loop below discards the rest anyway.
+		$payload = $this->map_product( $product, $required, ! $names_product );
+
+		if ( ! isset( $payload['variants'] ) || ! is_array( $payload['variants'] ) ) {
+			return $payload;
+		}
+
+		$variants = array();
+
+		foreach ( $payload['variants'] as $variant ) {
+			$correlations = array();
+
+			foreach ( $inputs as $input ) {
+				// The resolved variation decides the match: the raw id may carry
+				// leading zeros the resolver already normalized.
+				$names_variant = $input['variation_id'] > 0;
+
+				if ( $names_variant && (int) $input['variation_id'] !== (int) $variant['id'] ) {
+					continue;
+				}
+
+				$correlations[] = array(
+					'id'    => $input['id'],
+					'match' => $names_variant ? 'exact' : 'featured',
+				);
+			}
+
+			if ( empty( $correlations ) ) {
+				continue;
+			}
+
+			$variant['inputs'] = $correlations;
+			$variants[]        = $variant;
+		}
+
+		$payload['variants'] = $variants;
+
+		return $payload;
+	}
+
+	/**
+	 * Map a product for `POST /catalog/product`.
+	 *
+	 * A selection narrows the variants to those matching every selected option.
+	 * One matching no variation is relaxed from its last option until one does,
+	 * and `selected` reports the options kept, as UCP requires. A request naming
+	 * a variation returns it first, with its own options as the selection.
+	 *
+	 * @param \WC_Product $product WooCommerce product.
+	 * @param array       $selected Selected option values, each `{ name, label, id? }`.
+	 * @param int         $variation_id Variation the request named, or 0 when it named the product.
+	 * @return array
+	 */
+	public function map_detail_product( \WC_Product $product, array $selected = array(), int $variation_id = 0 ): array {
+		if ( ! $product instanceof \WC_Product_Variable || ( empty( $selected ) && $variation_id <= 0 ) ) {
+			return $this->map_product( $product );
+		}
+
+		$item = $this->item( $product );
+
+		// UCP: a variant id fully determines the selection state, so the
+		// request's `selected` is ignored rather than merged.
+		if ( $variation_id > 0 ) {
+			$selected = $this->variation_options_by_id( $item )[ $variation_id ] ?? array();
+		}
+
+		$matching = array();
+
+		while ( ! empty( $selected ) ) {
+			$matching = $this->match_selection( $item, $selected );
+
+			if ( ! empty( $matching ) ) {
+				break;
+			}
+
+			array_pop( $selected );
+		}
+
+		$required           = $variation_id > 0 ? array( $variation_id ) : array();
+		$mapped             = $this->map_item( $product, $item, $required, false, empty( $matching ) ? null : $matching );
+		$mapped['selected'] = $selected;
+
+		return $mapped;
+	}
+
+	/**
+	 * Determine whether a product exposes at least one purchasable catalog variant.
+	 *
+	 * Single source of truth for catalog eligibility: a product without variants
+	 * would violate the catalog `product` schema (`variants` requires
+	 * `minItems: 1`), so it is excluded from listings and treated as not found on
+	 * detail lookups. Simple products always qualify; a variable product needs at
+	 * least one visible variation: enabled and, when the store hides out-of-stock
+	 * items, in stock. That is the set the storefront and the Store API offer, read
+	 * through the data store's cached children list.
+	 *
+	 * @param \WC_Product $product WooCommerce product.
+	 * @return bool
+	 */
+	public function has_catalog_variants( \WC_Product $product ): bool {
+		if ( ! $product instanceof \WC_Product_Variable ) {
+			return true;
+		}
+
+		return ! empty( $product->get_visible_children() );
+	}
+
+	/**
+	 * Get the product schema, resolving the UCP one from the Store API container on first use.
+	 *
+	 * @return ProductSchema
+	 */
+	private function schema(): ProductSchema {
+		if ( null === $this->schema ) {
+			$this->schema = StoreApi::container()->get( SchemaController::class )->get( UcpProductSchema::IDENTIFIER );
+		}
+
+		return $this->schema;
+	}
+
+	/**
+	 * Build the Store API item response for a product or variation, as nested arrays.
+	 *
+	 * @param \WC_Product $product WooCommerce product.
+	 * @return array
+	 */
+	private function item( \WC_Product $product ): array {
+		// Nested values are stdClass; decode through JSON for uniform array access.
+		return (array) json_decode( (string) wp_json_encode( $this->schema()->get_item_response( $product ) ), true );
+	}
+
+	/**
+	 * Map a product from its item response.
+	 *
+	 * @param \WC_Product $product The product to map.
+	 * @param array       $item Its Store API item response.
+	 * @param array       $required_variation_ids Variation ids the per-product cap must not drop.
+	 * @param bool        $only_required Build only the named variations.
+	 * @param array|null  $candidates Variation ids to build from, or null for every visible one.
+	 * @return array
+	 */
+	private function map_item( \WC_Product $product, array $item, array $required_variation_ids, bool $only_required, ?array $candidates = null ): array {
+		$currency = $this->get_currency();
+		$variants = $this->build_variants( $product, $item, $currency, $required_variation_ids, $only_required, $candidates );
+
+		$mapped = array(
+			'id'          => (string) $item['id'],
+			'title'       => (string) $item['name'],
+			'description' => $this->build_description( $item ),
+			'price_range' => $this->build_price_range( $item['prices'], $currency ),
+			'variants'    => $variants,
+		);
+
+		$handle = (string) $item['slug'];
+		if ( '' !== $handle ) {
+			$mapped['handle'] = $handle;
+		}
+
+		$url = $item['permalink'];
+		if ( is_string( $url ) && '' !== $url ) {
+			$mapped['url'] = $url;
+		}
+
+		$categories = $this->build_categories( (array) $item['categories'] );
+		if ( ! empty( $categories ) ) {
+			$mapped['categories'] = $categories;
+		}
+
+		$media = $this->build_media( (array) $item['images'] );
+		if ( ! empty( $media ) ) {
+			$mapped['media'] = $media;
+		}
+
+		$options = $this->build_options( $item );
+		if ( ! empty( $options ) ) {
+			$mapped['options'] = $options;
+		}
+
+		$list_price_range = $this->build_list_price_range( $variants );
+		if ( null !== $list_price_range ) {
+			$mapped['list_price_range'] = $list_price_range;
+		}
+
+		$rating = $this->build_rating( $item );
+		if ( null !== $rating ) {
+			$mapped['rating'] = $rating;
+		}
+
+		$tags = $this->build_tags( (array) $item['tags'] );
+		if ( ! empty( $tags ) ) {
+			$mapped['tags'] = $tags;
+		}
+
+		return $mapped;
+	}
+
+	/**
+	 * Resolve the currency code used to label amounts.
+	 *
+	 * @return string
+	 */
+	private function get_currency(): string {
+		return '' !== $this->currency ? $this->currency : (string) get_woocommerce_currency();
+	}
+
+	/**
+	 * Build a `price` object from a Store API minor-unit amount.
+	 *
+	 * @param mixed  $amount Amount in minor units, as the Store API formats it.
+	 * @param string $currency ISO 4217 currency code.
+	 * @return array
+	 */
+	private function price( $amount, string $currency ): array {
+		return array(
+			'amount'   => (int) $amount,
+			'currency' => $currency,
+		);
+	}
+
+	/**
+	 * Build the description object, preferring the short description.
+	 *
+	 * @param array $item Store API item response.
+	 * @return array
+	 */
+	private function build_description( array $item ): array {
+		$html = trim( (string) $item['short_description'] );
+		if ( '' === $html ) {
+			$html = trim( (string) $item['description'] );
+		}
+
+		// Entities would reach the agent literally, so decode after stripping tags.
+		$plain = html_entity_decode( wp_strip_all_tags( $html ), ENT_QUOTES, get_bloginfo( 'charset' ) );
+
+		// Decoded `&nbsp;` is U+00A0. Agents match on this text, so normalize it to a
+		// plain space rather than leaving a character that breaks string comparison.
+		$plain = str_replace( "\xc2\xa0", ' ', $plain );
+
+		$description = array( 'plain' => trim( $plain ) );
+
+		if ( '' !== $html ) {
+			$description['html'] = $html;
+		}
+
+		return $description;
+	}
+
+	/**
+	 * Build the price range from Store API prices.
+	 *
+	 * `price_range` is only set for variable products whose variations differ in
+	 * price; everything else is a single price.
+	 *
+	 * @param array  $prices Store API `prices` data.
+	 * @param string $currency ISO 4217 currency code.
+	 * @return array
+	 */
+	private function build_price_range( $prices, string $currency ): array {
+		$range = $prices['price_range'] ?? null;
+
+		if ( is_array( $range ) ) {
+			return array(
+				'min' => $this->price( $range['min_amount'], $currency ),
+				'max' => $this->price( $range['max_amount'], $currency ),
+			);
+		}
+
+		return array(
+			'min' => $this->price( $prices['price'], $currency ),
+			'max' => $this->price( $prices['price'], $currency ),
+		);
+	}
+
+	/**
+	 * Build the list price range from the variants built for the product.
+	 *
+	 * Spans every variant's regular price, but only when some variant is genuinely
+	 * discounted; a variant carries `list_price` only in that case.
+	 *
+	 * @param array $variants Built variants.
+	 * @return array|null
+	 */
+	private function build_list_price_range( array $variants ) {
+		$has_discount = false;
+		$regular      = array();
+
+		foreach ( $variants as $variant ) {
+			if ( isset( $variant['list_price'] ) ) {
+				$has_discount = true;
+				$regular[]    = $variant['list_price']['amount'];
+			} else {
+				$regular[] = $variant['price']['amount'];
+			}
+		}
+
+		if ( ! $has_discount || empty( $regular ) ) {
+			return null;
+		}
+
+		$currency = $variants[0]['price']['currency'];
+
+		return array(
+			'min' => $this->price( min( $regular ), $currency ),
+			'max' => $this->price( max( $regular ), $currency ),
+		);
+	}
+
+	/**
+	 * Build variants from product, bounded by the per-product cap.
+	 *
+	 * The loop stops at the cap rather than building everything and slicing, so
+	 * variations past it are never loaded. Named variations go first, so the cap
+	 * can only drop variants nobody asked for by id.
+	 *
+	 * @param \WC_Product $product WooCommerce product.
+	 * @param array       $item Its Store API item response.
+	 * @param string      $currency ISO 4217 currency code.
+	 * @param array       $required_variation_ids Variation ids the request named directly.
+	 * @param bool        $only_required Build nothing beyond $required_variation_ids.
+	 * @param array|null  $candidates Variation ids to build from, or null for every visible one.
+	 * @return array
+	 */
+	private function build_variants( \WC_Product $product, array $item, string $currency, array $required_variation_ids, bool $only_required, ?array $candidates ): array {
+		if ( ! $product instanceof \WC_Product_Variable ) {
+			return array( $this->build_simple_variant( $item, $currency ) );
+		}
+
+		$children = array_map( 'intval', null === $candidates ? $product->get_visible_children() : $candidates );
+		$required = array_values( array_intersect( $children, array_map( 'intval', $required_variation_ids ) ) );
+		$options  = $this->variation_options_by_id( $item );
+
+		if ( $only_required && ! empty( $required ) ) {
+			// Bounded by the request's own id cap; nothing left out, nothing to
+			// disclose.
+			$variants              = $this->build_variation_list( $required, $options, $currency );
+			$this->variant_budget -= count( $variants );
+
+			return $variants;
+		}
+
+		$required_first = array_merge( $required, array_values( array_diff( $children, $required ) ) );
+
+		// The floor keeps named variations and at least one variant: an empty
+		// `variants` array would violate the schema's `minItems: 1`.
+		$floor    = max( 1, count( $required ) );
+		$cap      = max( $floor, min( self::MAX_VARIANTS_PER_PRODUCT, $this->variant_budget ) );
+		$variants = $this->build_variation_list( $required_first, $options, $currency, $cap );
+
+		$this->variant_budget -= count( $variants );
+
+		$this->record_truncation( $item, count( $variants ), count( $children ) );
+
+		return $variants;
+	}
+
+	/**
+	 * Hydrate and transform variations in order, stopping at an optional limit.
+	 *
+	 * The limit is applied while iterating rather than by slicing afterwards, so
+	 * variations past it are never loaded.
+	 *
+	 * @param array    $variation_ids Variation ids, in the order to build them.
+	 * @param array    $options Selected options keyed by variation id, from the parent item.
+	 * @param string   $currency ISO 4217 currency code.
+	 * @param int|null $limit Maximum variants to build, or null for no limit.
+	 * @return array
+	 */
+	private function build_variation_list( array $variation_ids, array $options, string $currency, ?int $limit = null ): array {
+		$variants = array();
+
+		foreach ( $variation_ids as $variation_id ) {
+			if ( null !== $limit && count( $variants ) >= $limit ) {
+				break;
+			}
+
+			$variation = wc_get_product( $variation_id );
+			if ( ! $variation instanceof \WC_Product_Variation ) {
+				continue;
+			}
+
+			$variants[] = $this->build_variation_variant( $this->item( $variation ), $options[ (int) $variation_id ] ?? array(), $currency );
+		}
+
+		return $variants;
+	}
+
+	/**
+	 * Note that a product's variant list came back short, for the caller to report.
+	 *
+	 * @param array $item Store API item response of the variable product.
+	 * @param int   $returned Number of variants actually built.
+	 * @param int   $total Number of variants there were to build.
+	 */
+	private function record_truncation( array $item, int $returned, int $total ): void {
+		if ( $returned >= $total ) {
+			return;
+		}
+
+		$this->truncations[ (int) $item['id'] ] = array(
+			'title'    => (string) $item['name'],
+			'returned' => $returned,
+			'total'    => $total,
+		);
+	}
+
+	/**
+	 * Build a variant entry from a simple product's item response.
+	 *
+	 * @param array  $item Store API item response.
+	 * @param string $currency ISO 4217 currency code.
+	 * @return array
+	 */
+	private function build_simple_variant( array $item, string $currency ): array {
+		$variant = array(
+			'id'           => (string) $item['id'],
+			'title'        => (string) $item['name'],
+			'description'  => $this->build_description( $item ),
+			'price'        => $this->price( $item['prices']['price'], $currency ),
+			'availability' => array( 'available' => $item['is_purchasable'] && $item['is_in_stock'] ),
+		);
+
+		$sku = (string) $item['sku'];
+		if ( '' !== $sku ) {
+			$variant['sku'] = $sku;
+		}
+
+		$list_price = $this->build_variant_list_price( $item['prices'], $currency );
+		if ( null !== $list_price ) {
+			$variant['list_price'] = $list_price;
+		}
+
+		return $variant;
+	}
+
+	/**
+	 * Build a variant entry from a variation's item response.
+	 *
+	 * @param array  $item Store API item response of the variation.
+	 * @param array  $options Selected options, each `{ name, label }`.
+	 * @param string $currency ISO 4217 currency code.
+	 * @return array
+	 */
+	private function build_variation_variant( array $item, array $options, string $currency ): array {
+		$labels = array_column( $options, 'label' );
+
+		$variant = array(
+			'id'           => (string) $item['id'],
+			'title'        => empty( $labels ) ? (string) $item['name'] : implode( ' / ', $labels ),
+			'description'  => $this->build_description( $item ),
+			'price'        => $this->price( $item['prices']['price'], $currency ),
+			'availability' => array( 'available' => $item['is_purchasable'] && $item['is_in_stock'] ),
+		);
+
+		$sku = (string) $item['sku'];
+		if ( '' !== $sku ) {
+			$variant['sku'] = $sku;
+		}
+
+		$list_price = $this->build_variant_list_price( $item['prices'], $currency );
+		if ( null !== $list_price ) {
+			$variant['list_price'] = $list_price;
+		}
+
+		if ( ! empty( $options ) ) {
+			$variant['options'] = $options;
+		}
+
+		$media = $this->build_media( array_slice( (array) $item['images'], 0, 1 ) );
+		if ( ! empty( $media ) ) {
+			$variant['media'] = $media;
+		}
+
+		return $variant;
+	}
+
+	/**
+	 * Build a variant's list price when it is genuinely discounted.
+	 *
+	 * Compares against the effective price so expired or scheduled sales are excluded.
+	 *
+	 * @param array  $prices Store API `prices` data.
+	 * @param string $currency ISO 4217 currency code.
+	 * @return array|null
+	 */
+	private function build_variant_list_price( $prices, string $currency ) {
+		if ( (int) $prices['regular_price'] <= (int) $prices['price'] ) {
+			return null;
+		}
+
+		return $this->price( $prices['regular_price'], $currency );
+	}
+
+	/**
+	 * Index term names by slug for each variation attribute of a product.
+	 *
+	 * @param array $item Store API item response of the variable product.
+	 * @return array Attribute label => [ slug => term name ].
+	 */
+	private function term_names( array $item ): array {
+		$names = array();
+
+		foreach ( (array) $item['attributes'] as $attribute ) {
+			if ( empty( $attribute['has_variations'] ) ) {
+				continue;
+			}
+
+			foreach ( (array) $attribute['terms'] as $term ) {
+				$names[ (string) $attribute['name'] ][ (string) $term['slug'] ] = (string) $term['name'];
+			}
+		}
+
+		return $names;
+	}
+
+	/**
+	 * Build each visible variation's selected options from the parent item.
+	 *
+	 * An attribute left as "Any" on a variation has a null value and is omitted.
+	 *
+	 * @param array $item Store API item response of the variable product.
+	 * @return array Variation id => list of `{ name, label }`.
+	 */
+	private function variation_options_by_id( array $item ): array {
+		$names   = $this->term_names( $item );
+		$options = array();
+
+		foreach ( (array) $item['variations'] as $variation ) {
+			$selected = array();
+
+			foreach ( (array) $variation['attributes'] as $attribute ) {
+				if ( null === $attribute['value'] || '' === (string) $attribute['value'] ) {
+					continue;
+				}
+
+				$name       = (string) $attribute['name'];
+				$value      = (string) $attribute['value'];
+				$selected[] = array(
+					'name'  => $name,
+					'label' => $names[ $name ][ $value ] ?? $value,
+				);
+			}
+
+			$options[ (int) $variation['id'] ] = $selected;
+		}
+
+		return $options;
+	}
+
+	/**
+	 * Build product options for variable products.
+	 *
+	 * Lists the values the visible variations actually use, in the attribute's
+	 * own term order; an "Any" variation offers every term.
+	 *
+	 * @param array $item Store API item response.
+	 * @return array
+	 */
+	private function build_options( array $item ): array {
+		$variations = (array) $item['variations'];
+		if ( empty( $variations ) ) {
+			return array();
+		}
+
+		$used = array();
+		$any  = array();
+		foreach ( $variations as $variation ) {
+			foreach ( (array) $variation['attributes'] as $attribute ) {
+				$name = (string) $attribute['name'];
+				if ( null === $attribute['value'] || '' === (string) $attribute['value'] ) {
+					$any[ $name ] = true;
+					continue;
+				}
+				$used[ $name ][ (string) $attribute['value'] ] = true;
+			}
+		}
+
+		$options = array();
+
+		foreach ( (array) $item['attributes'] as $attribute ) {
+			if ( empty( $attribute['has_variations'] ) ) {
+				continue;
+			}
+
+			$name   = (string) $attribute['name'];
+			$values = array();
+
+			foreach ( (array) $attribute['terms'] as $term ) {
+				if ( isset( $any[ $name ] ) || isset( $used[ $name ][ (string) $term['slug'] ] ) ) {
+					$values[] = array( 'label' => (string) $term['name'] );
+				}
+			}
+
+			if ( ! empty( $values ) ) {
+				$options[] = array(
+					'name'   => $name,
+					'values' => $values,
+				);
+			}
+		}
+
+		return $options;
+	}
+
+	/**
+	 * Build media entries from Store API images.
+	 *
+	 * @param array $images Store API image objects.
+	 * @return array
+	 */
+	private function build_media( array $images ): array {
+		$media = array();
+
+		foreach ( array_slice( $images, 0, self::MAX_MEDIA_PER_PRODUCT ) as $image ) {
+			$url = $image['src'] ?? '';
+			if ( ! is_string( $url ) || '' === $url ) {
+				continue;
+			}
+
+			$entry = array(
+				'type' => 'image',
+				'url'  => $url,
+			);
+
+			if ( is_string( $image['alt'] ) && '' !== $image['alt'] ) {
+				$entry['alt_text'] = $image['alt'];
+			}
+
+			$media[] = $entry;
+		}
+
+		return $media;
+	}
+
+	/**
+	 * Build product categories from Store API terms.
+	 *
+	 * @param array $terms Store API category objects.
+	 * @return array
+	 */
+	private function build_categories( array $terms ): array {
+		$categories = array();
+
+		foreach ( array_slice( $terms, 0, self::MAX_TERMS_PER_PRODUCT ) as $term ) {
+			$categories[] = array(
+				'id'    => (string) $term['id'],
+				'name'  => (string) $term['name'],
+				'value' => (string) $term['slug'],
+			);
+		}
+
+		return $categories;
+	}
+
+	/**
+	 * Build product rating.
+	 *
+	 * @param array $item Store API item response.
+	 * @return array|null
+	 */
+	private function build_rating( array $item ) {
+		$average = (float) $item['average_rating'];
+		if ( $average <= 0 ) {
+			return null;
+		}
+
+		$rating = array(
+			'value'     => $average,
+			'scale_min' => 1,
+			'scale_max' => 5,
+		);
+
+		$count = (int) $item['review_count'];
+		if ( $count > 0 ) {
+			$rating['count'] = $count;
+		}
+
+		return $rating;
+	}
+
+	/**
+	 * Build product tags from Store API terms.
+	 *
+	 * @param array $terms Store API tag objects.
+	 * @return array
+	 */
+	private function build_tags( array $terms ): array {
+		$tags = array();
+
+		foreach ( array_slice( $terms, 0, self::MAX_TERMS_PER_PRODUCT ) as $term ) {
+			$tags[] = (string) $term['name'];
+		}
+
+		return $tags;
+	}
+
+	/**
+	 * Find the visible variations carrying every selected option value.
+	 *
+	 * Matches on the parent item's variation attributes, so no variation is loaded
+	 * to be rejected. A label matches a term's name or its slug, case-insensitively,
+	 * and a variation left as "Any" for an attribute accepts every label, as the
+	 * storefront does.
+	 *
+	 * @param array $item Store API item response of the variable product.
+	 * @param array $selected Selected option values, each `{ name, label }`.
+	 * @return array Matching variation ids.
+	 */
+	private function match_selection( array $item, array $selected ): array {
+		$selection = array();
+		foreach ( $selected as $option ) {
+			if ( isset( $option['name'], $option['label'] ) ) {
+				$selection[ strtolower( (string) $option['name'] ) ] = strtolower( (string) $option['label'] );
+			}
+		}
+
+		if ( empty( $selection ) ) {
+			return array();
+		}
+
+		$names = $this->term_names( $item );
+
+		// A name no variation attribute carries can never match; without this
+		// check the "Any" rule below would accept it on every variation.
+		$known = array_fill_keys( array_map( 'strtolower', array_keys( $names ) ), true );
+		if ( array_diff_key( $selection, $known ) ) {
+			return array();
+		}
+
+		$matching = array();
+
+		foreach ( (array) $item['variations'] as $variation ) {
+			$values = array();
+			foreach ( (array) $variation['attributes'] as $attribute ) {
+				if ( null === $attribute['value'] || '' === (string) $attribute['value'] ) {
+					continue;
+				}
+
+				$name  = (string) $attribute['name'];
+				$value = (string) $attribute['value'];
+
+				$values[ strtolower( $name ) ] = array(
+					strtolower( $value ),
+					strtolower( $names[ $name ][ $value ] ?? $value ),
+				);
+			}
+
+			foreach ( $selection as $name => $label ) {
+				if ( isset( $values[ $name ] ) && ! in_array( $label, $values[ $name ], true ) ) {
+					continue 2;
+				}
+			}
+
+			$matching[] = (int) $variation['id'];
+		}
+
+		return $matching;
+	}
+}
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/AbstractCatalogRoute.php b/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/AbstractCatalogRoute.php
new file mode 100644
index 00000000000..211e8b58a21
--- /dev/null
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/AbstractCatalogRoute.php
@@ -0,0 +1,282 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\StoreApi\Routes\V1\Ucp;
+
+use Automattic\WooCommerce\Internal\ProductFeed\Mapping\UcpProductMapper;
+use Automattic\WooCommerce\StoreApi\Routes\V1\AbstractRoute;
+use Automattic\WooCommerce\StoreApi\Schemas\V1\Ucp\UcpProductSchema;
+use Automattic\WooCommerce\StoreApi\Utilities\UcpUtils;
+
+/**
+ * Shared behaviour of the UCP catalog routes.
+ */
+abstract class AbstractCatalogRoute extends AbstractRoute {
+	/**
+	 * The routes schema.
+	 *
+	 * @var string
+	 */
+	const SCHEMA_TYPE = 'ucp-catalog';
+
+	/**
+	 * Product mapper, holding per-response state.
+	 *
+	 * @var UcpProductMapper|null
+	 */
+	private $mapper;
+
+	/**
+	 * The UCP capability this route serves, one of the UcpUtils::CAPABILITY_* constants.
+	 *
+	 * @return string
+	 */
+	abstract protected function get_capability(): string;
+
+	/**
+	 * Build the `ucp` envelope for a response from this route.
+	 *
+	 * @param string $status Response status, `success` or `error`.
+	 * @return array
+	 */
+	protected function ucp_metadata( string $status ): array {
+		return UcpUtils::response_metadata( $this->get_capability(), $status );
+	}
+
+	/**
+	 * Get method arguments for this REST route.
+	 *
+	 * @return array An array of endpoints.
+	 */
+	public function get_args() {
+		return array(
+			array(
+				'methods'             => \WP_REST_Server::CREATABLE,
+				'callback'            => array( $this, 'get_response' ),
+				'permission_callback' => '__return_true',
+				'args'                => $this->get_request_args(),
+			),
+			'schema' => array( $this->schema, 'get_public_item_schema' ),
+		);
+	}
+
+	/**
+	 * Request body arguments accepted by this route.
+	 *
+	 * @return array
+	 */
+	protected function get_request_args(): array {
+		return array(
+			'context' => array(
+				'description' => __( 'Provisional buyer signals for relevance and localization.', 'woocommerce' ),
+				'type'        => 'object',
+			),
+		);
+	}
+
+	/**
+	 * Get the product mapper for the response being built.
+	 *
+	 * @return UcpProductMapper
+	 */
+	protected function mapper(): UcpProductMapper {
+		if ( null === $this->mapper ) {
+			/**
+			 * The controller returns the registered subclass; typed for static analysis.
+			 *
+			 * @var UcpProductSchema $schema
+			 */
+			$schema       = $this->schema_controller->get( UcpProductSchema::IDENTIFIER );
+			$this->mapper = new UcpProductMapper( $this->store_currency(), $schema );
+		}
+
+		return $this->mapper;
+	}
+
+	/**
+	 * Get the store's configured currency code.
+	 *
+	 * @return string
+	 */
+	protected function store_currency(): string {
+		return (string) get_woocommerce_currency();
+	}
+
+	/**
+	 * Extract the recognized context fields from a request.
+	 *
+	 * @param \WP_REST_Request $request Request object.
+	 *
+	 * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
+	 *
+	 * @return array
+	 */
+	protected function get_context( \WP_REST_Request $request ): array {
+		$raw = $request->get_param( 'context' );
+
+		if ( ! is_array( $raw ) ) {
+			return array();
+		}
+
+		$context = array();
+
+		foreach ( array( 'address_country', 'address_region', 'postal_code', 'intent', 'language', 'currency' ) as $key ) {
+			if ( isset( $raw[ $key ] ) && is_scalar( $raw[ $key ] ) && '' !== (string) $raw[ $key ] ) {
+				$context[ $key ] = (string) $raw[ $key ];
+			}
+		}
+
+		return $context;
+	}
+
+	/**
+	 * Build a transport-level error response.
+	 *
+	 * @param string $code Error code.
+	 * @param string $message Error message.
+	 * @param string $severity Error severity.
+	 * @param int    $status HTTP status.
+	 * @return \WP_REST_Response
+	 */
+	protected function ucp_error( string $code, string $message, string $severity, int $status ): \WP_REST_Response {
+		return new \WP_REST_Response(
+			array(
+				'ucp'      => $this->ucp_metadata( 'error' ),
+				'messages' => array(
+					array(
+						'type'         => 'error',
+						'code'         => $code,
+						'content'      => $message,
+						'severity'     => $severity,
+						'path'         => '$',
+						'content_type' => 'plain',
+					),
+				),
+			),
+			$status
+		);
+	}
+
+	/**
+	 * Resolve a catalog product by product or variation ID.
+	 *
+	 * The lookup schema requires variant identifiers to be resolvable, so an
+	 * identifier naming a variation resolves to its parent product — the variation
+	 * is returned as one of that product's variants, not as a self-parented product
+	 * of its own. Callers are told which variation was named so they can correlate
+	 * it back to the request.
+	 *
+	 * @param string $identifier Product ID or variation ID.
+	 * @return array|null `{ product, variation_id }`, where `variation_id` is 0 when the identifier
+	 *                    named the product itself, or null when nothing catalog-visible matched.
+	 */
+	protected function resolve_product( string $identifier ) {
+		$product_id   = ctype_digit( $identifier ) ? (int) $identifier : 0;
+		$product      = $product_id > 0 ? wc_get_product( $product_id ) : null;
+		$variation_id = 0;
+
+		if ( $product instanceof \WC_Product_Variation ) {
+			$variation_id = (int) $product->get_id();
+			$product      = wc_get_product( $product->get_parent_id() );
+		}
+
+		// Catalog visibility as the storefront applies it: published, not hidden
+		// from the catalog, and in stock when the store hides out-of-stock items.
+		if ( ! $product instanceof \WC_Product || ! $product->is_visible() ) {
+			return null;
+		}
+
+		if ( ! $this->mapper()->has_catalog_variants( $product ) ) {
+			return null;
+		}
+
+		// A named variation the storefront would not offer — disabled, or out of
+		// stock while the store hides out-of-stock items — is a miss even when
+		// its parent is otherwise fine.
+		if ( $variation_id > 0 && ( ! $product instanceof \WC_Product_Variable || ! in_array( $variation_id, array_map( 'intval', $product->get_visible_children() ), true ) ) ) {
+			return null;
+		}
+
+		return array(
+			'product'      => $product,
+			'variation_id' => $variation_id,
+		);
+	}
+
+	/**
+	 * Build `messages` entries for products whose variant list was cut short.
+	 *
+	 * UCP has no variant pagination, so this message is the only disclosure of a
+	 * shortened list — without it an agent reports the missing variants as
+	 * nonexistent. `path` is the RFC 9535 JSONPath of the affected list.
+	 *
+	 * @param array  $truncations Keyed by product id, from UcpProductMapper::take_truncations().
+	 * @param array  $products Mapped products, in response order.
+	 * @param string $path_prefix JSONPath of the products container.
+	 * @return array
+	 */
+	protected function truncation_messages( array $truncations, array $products, string $path_prefix ): array {
+		if ( empty( $truncations ) ) {
+			return array();
+		}
+
+		$messages = array();
+
+		foreach ( $products as $index => $product ) {
+			$product_id = isset( $product['id'] ) ? (int) $product['id'] : 0;
+
+			if ( ! isset( $truncations[ $product_id ] ) ) {
+				continue;
+			}
+
+			$truncation = $truncations[ $product_id ];
+
+			$messages[] = array(
+				'type'         => 'info',
+				'code'         => 'variants_truncated',
+				'content'      => sprintf(
+					/* translators: 1: number of variants returned, 2: total published variants, 3: product title. */
+					__( 'Showing %1$d of %2$d variants for "%3$s". Request this product directly, or name a variant by id, to reach the others.', 'woocommerce' ),
+					$truncation['returned'],
+					$truncation['total'],
+					$truncation['title']
+				),
+				'path'         => '$.product' === $path_prefix ? '$.product.variants' : sprintf( '%s[%d].variants', $path_prefix, $index ),
+				'content_type' => 'plain',
+			);
+		}
+
+		return $messages;
+	}
+
+	/**
+	 * Build a non-fatal warning when the requested context currency differs from the store currency.
+	 *
+	 * Catalog amounts are always expressed in the store's own currency; the store performs no FX
+	 * conversion. When an agent requests a different currency we ignore it for labeling and surface
+	 * an informational message so the difference is not silently dropped.
+	 *
+	 * @param array  $context Request context.
+	 * @param string $store_currency Store currency code used for labeling.
+	 * @return array|null
+	 */
+	protected function currency_warning( array $context, string $store_currency ) {
+		if ( ! isset( $context['currency'] ) || 0 === strcasecmp( $context['currency'], $store_currency ) ) {
+			return null;
+		}
+
+		return array(
+			'type'         => 'warning',
+			'code'         => 'currency_not_supported',
+			'content'      => sprintf(
+				/* translators: 1: requested currency code, 2: store currency code. */
+				__( 'This store only transacts in %2$s and does not convert currencies. Prices are returned in %2$s; the requested currency %1$s was ignored.', 'woocommerce' ),
+				$context['currency'],
+				$store_currency
+			),
+			'severity'     => 'recoverable',
+			'path'         => '$.context.currency',
+			'content_type' => 'plain',
+		);
+	}
+}
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/CatalogLookup.php b/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/CatalogLookup.php
new file mode 100644
index 00000000000..6fe919932bb
--- /dev/null
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/CatalogLookup.php
@@ -0,0 +1,182 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\StoreApi\Routes\V1\Ucp;
+
+use Automattic\WooCommerce\StoreApi\Utilities\UcpUtils;
+
+/**
+ * UCP `POST /catalog/lookup` route.
+ */
+class CatalogLookup extends AbstractCatalogRoute {
+	/**
+	 * The route identifier.
+	 *
+	 * @var string
+	 */
+	const IDENTIFIER = 'ucp-catalog-lookup';
+
+	/**
+	 * Maximum number of `ids` honoured per lookup request.
+	 *
+	 * Each id costs a product load plus a full transform on an unauthenticated
+	 * endpoint. Unlike category filters, an over-long list is rejected rather than
+	 * truncated: silently dropping requested ids would report existing products as
+	 * missing.
+	 */
+	const MAX_LOOKUP_IDS = 50;
+
+	/**
+	 * Get the path of this REST route.
+	 *
+	 * @return string
+	 */
+	public function get_path() {
+		return self::get_path_regex();
+	}
+
+	/**
+	 * Get the path of this rest route.
+	 *
+	 * @return string
+	 */
+	public static function get_path_regex() {
+		return '/catalog/lookup';
+	}
+
+	/**
+	 * The UCP capability this route serves.
+	 *
+	 * @return string
+	 */
+	protected function get_capability(): string {
+		return UcpUtils::CAPABILITY_CATALOG_LOOKUP;
+	}
+
+	/**
+	 * Request body arguments accepted by this route.
+	 *
+	 * `ids` is deliberately not marked required: a missing list answers with the
+	 * UCP `VALIDATION_ERROR` envelope rather than `rest_missing_callback_param`.
+	 *
+	 * @return array
+	 */
+	protected function get_request_args(): array {
+		return array_merge(
+			parent::get_request_args(),
+			array(
+				'ids' => array(
+					'description' => __( 'Product or variant identifiers to look up.', 'woocommerce' ),
+					'type'        => 'array',
+					'items'       => array( 'type' => array( 'string', 'integer' ) ),
+				),
+			)
+		);
+	}
+
+	/**
+	 * Handle the request.
+	 *
+	 * @param \WP_REST_Request $request Request object.
+	 *
+	 * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
+	 *
+	 * @return \WP_REST_Response
+	 */
+	protected function get_route_post_response( \WP_REST_Request $request ) {
+		$raw_ids = $request->get_param( 'ids' );
+
+		if ( ! is_array( $raw_ids ) || empty( $raw_ids ) ) {
+			return $this->ucp_error( 'VALIDATION_ERROR', __( 'ids is required.', 'woocommerce' ), 'recoverable', 400 );
+		}
+
+		// Deduplicate before counting: repeating one id is not a reason to reject,
+		// and each distinct id is what actually costs a product load.
+		$ids = array_values( array_unique( array_map( 'strval', $raw_ids ) ) );
+
+		if ( count( $ids ) > self::MAX_LOOKUP_IDS ) {
+			// `request_too_large` is the code UCP conformance names for a request
+			// over a batch size limit, which is exactly what an over-long id list is.
+			return $this->ucp_error(
+				'request_too_large',
+				sprintf(
+					/* translators: %d: maximum number of ids accepted per lookup request. */
+					__( 'ids accepts at most %d entries per request.', 'woocommerce' ),
+					self::MAX_LOOKUP_IDS
+				),
+				'recoverable',
+				400
+			);
+		}
+
+		$messages = array();
+
+		$currency_warning = $this->currency_warning( $this->get_context( $request ), $this->store_currency() );
+		if ( null !== $currency_warning ) {
+			$messages[] = $currency_warning;
+		}
+
+		// Several identifiers can resolve to the same product (a product id and one
+		// of its variation ids, say), so group them and emit one entry per product
+		// carrying every identifier that resolved to it.
+		$groups = array();
+
+		foreach ( $ids as $id ) {
+			$resolved = $this->resolve_product( $id );
+
+			if ( null === $resolved ) {
+				$messages[] = array(
+					'type'         => 'info',
+					'code'         => 'not_found',
+					'content'      => sprintf(
+						/* translators: %s: product identifier. */
+						__( 'Product "%s" not found.', 'woocommerce' ),
+						$id
+					),
+					'path'         => '$.ids',
+					'content_type' => 'plain',
+				);
+				continue;
+			}
+
+			$product_id = (int) $resolved['product']->get_id();
+
+			if ( ! isset( $groups[ $product_id ] ) ) {
+				$groups[ $product_id ] = array(
+					'product' => $resolved['product'],
+					'inputs'  => array(),
+				);
+			}
+
+			$groups[ $product_id ]['inputs'][] = array(
+				'id'           => $id,
+				'variation_id' => $resolved['variation_id'],
+			);
+		}
+
+		// One response spans several mapping calls here, so the per-response state
+		// is started explicitly rather than by the mapper.
+		$this->mapper()->begin_response();
+
+		$products = array();
+		foreach ( $groups as $group ) {
+			$products[] = $this->mapper()->map_lookup_product( $group['product'], $group['inputs'] );
+		}
+
+		$messages = array_merge(
+			$messages,
+			$this->truncation_messages( $this->mapper()->take_truncations(), $products, '$.products' )
+		);
+
+		$response = array(
+			'ucp'      => $this->ucp_metadata( 'success' ),
+			'products' => $products,
+		);
+
+		if ( ! empty( $messages ) ) {
+			$response['messages'] = $messages;
+		}
+
+		return new \WP_REST_Response( $response, 200 );
+	}
+}
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/CatalogProduct.php b/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/CatalogProduct.php
new file mode 100644
index 00000000000..5e34b8aa43b
--- /dev/null
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/CatalogProduct.php
@@ -0,0 +1,183 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\StoreApi\Routes\V1\Ucp;
+
+use Automattic\WooCommerce\StoreApi\Utilities\UcpUtils;
+
+/**
+ * UCP `POST /catalog/product` route.
+ */
+class CatalogProduct extends AbstractCatalogRoute {
+	/**
+	 * The route identifier.
+	 *
+	 * @var string
+	 */
+	const IDENTIFIER = 'ucp-catalog-product';
+
+	/**
+	 * Maximum number of `selected` options honoured per product request.
+	 *
+	 * Every option is compared against every attribute of every variation.
+	 */
+	const MAX_SELECTED_OPTIONS = 25;
+
+	/**
+	 * Get the path of this REST route.
+	 *
+	 * @return string
+	 */
+	public function get_path() {
+		return self::get_path_regex();
+	}
+
+	/**
+	 * Get the path of this rest route.
+	 *
+	 * @return string
+	 */
+	public static function get_path_regex() {
+		return '/catalog/product';
+	}
+
+	/**
+	 * The UCP capability this route serves.
+	 *
+	 * @return string
+	 */
+	protected function get_capability(): string {
+		return UcpUtils::CAPABILITY_CATALOG_LOOKUP;
+	}
+
+	/**
+	 * Request body arguments accepted by this route.
+	 *
+	 * `id` is deliberately not marked required: a missing id answers with the UCP
+	 * `VALIDATION_ERROR` envelope rather than `rest_missing_callback_param`.
+	 *
+	 * @return array
+	 */
+	protected function get_request_args(): array {
+		return array_merge(
+			parent::get_request_args(),
+			array(
+				'id'       => array(
+					'description' => __( 'Product or variant identifier.', 'woocommerce' ),
+					'type'        => array( 'string', 'integer' ),
+				),
+				'selected' => array(
+					'description' => __( 'Option selections used to narrow the returned variants.', 'woocommerce' ),
+					'type'        => 'array',
+				),
+			)
+		);
+	}
+
+	/**
+	 * Handle the request.
+	 *
+	 * @param \WP_REST_Request $request Request object.
+	 *
+	 * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
+	 *
+	 * @return \WP_REST_Response
+	 */
+	protected function get_route_post_response( \WP_REST_Request $request ) {
+		$id = trim( strval( $request->get_param( 'id' ) ) );
+
+		if ( '' === $id ) {
+			return $this->ucp_error( 'VALIDATION_ERROR', __( 'id is required.', 'woocommerce' ), 'recoverable', 400 );
+		}
+
+		$selected = (array) ( $request->get_param( 'selected' ) ?? array() );
+
+		// Checked before the product is loaded: an over-long selection is refused
+		// without paying for the lookup it would have been applied to.
+		if ( count( $selected ) > self::MAX_SELECTED_OPTIONS ) {
+			return $this->ucp_error(
+				'request_too_large',
+				sprintf(
+					/* translators: %d: maximum number of selected options accepted per request. */
+					__( 'selected accepts at most %d options per request.', 'woocommerce' ),
+					self::MAX_SELECTED_OPTIONS
+				),
+				'recoverable',
+				400
+			);
+		}
+
+		$resolved = $this->resolve_product( $id );
+
+		if ( null === $resolved ) {
+			// A missing product is reported in the envelope, not as a 404: UCP
+			// clients read `ucp.status` and `messages`, not the HTTP status.
+			return new \WP_REST_Response(
+				array(
+					'ucp'      => $this->ucp_metadata( 'error' ),
+					'messages' => array(
+						array(
+							'type'         => 'error',
+							'code'         => 'not_found',
+							'content'      => __( 'Product not found.', 'woocommerce' ),
+							'severity'     => 'unrecoverable',
+							'path'         => '$.id',
+							'content_type' => 'plain',
+						),
+					),
+				),
+				200
+			);
+		}
+
+		$this->mapper()->begin_response();
+		$product = $this->mapper()->map_detail_product( $resolved['product'], $this->normalize_selected( $selected ), (int) $resolved['variation_id'] );
+
+		$response = array(
+			'ucp'     => $this->ucp_metadata( 'success' ),
+			'product' => $product,
+		);
+
+		$messages = $this->truncation_messages( $this->mapper()->take_truncations(), array( $product ), '$.product' );
+
+		$currency_warning = $this->currency_warning( $this->get_context( $request ), $this->store_currency() );
+		if ( null !== $currency_warning ) {
+			array_unshift( $messages, $currency_warning );
+		}
+
+		if ( ! empty( $messages ) ) {
+			$response['messages'] = $messages;
+		}
+
+		return new \WP_REST_Response( $response, 200 );
+	}
+
+	/**
+	 * Normalize selected options from the request payload.
+	 *
+	 * @param array $selected Raw selected options.
+	 * @return array
+	 */
+	private function normalize_selected( array $selected ): array {
+		$normalized = array();
+
+		foreach ( $selected as $option ) {
+			if ( ! is_array( $option ) || ! isset( $option['name'], $option['label'] ) ) {
+				continue;
+			}
+
+			$entry = array(
+				'name'  => strval( $option['name'] ),
+				'label' => strval( $option['label'] ),
+			);
+
+			if ( isset( $option['id'] ) ) {
+				$entry['id'] = strval( $option['id'] );
+			}
+
+			$normalized[] = $entry;
+		}
+
+		return $normalized;
+	}
+}
diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/CatalogSearch.php b/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/CatalogSearch.php
new file mode 100644
index 00000000000..32dabbbbf02
--- /dev/null
+++ b/plugins/woocommerce/src/StoreApi/Routes/V1/Ucp/CatalogSearch.php
@@ -0,0 +1,380 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\StoreApi\Routes\V1\Ucp;
+
+use Automattic\WooCommerce\Enums\CatalogVisibility;
+use Automattic\WooCommerce\StoreApi\Utilities\ProductQuery;
+use Automattic\WooCommerce\StoreApi\Utilities\UcpUtils;
+
+/**
+ * UCP `POST /catalog/search` route.
+ */
+class CatalogSearch extends AbstractCatalogRoute {
+	/**
+	 * The route identifier.
+	 *
+	 * @var string
+	 */
+	const IDENTIFIER = 'ucp-catalog-search';
+
+	/**
+	 * Maximum number of `filters.categories` references honoured per request.
+	 *
+	 * Each reference costs a term lookup on an unauthenticated endpoint.
+	 */
+	const MAX_CATEGORY_FILTERS = 25;
+
+	/**
+	 * Maximum accepted length of the free-text `query`, in bytes.
+	 *
+	 * The term reaches a `LIKE` scan over product titles and SKUs, so its length
+	 * is paid per row examined.
+	 */
+	const MAX_QUERY_LENGTH = 256;
+
+	/**
+	 * Get the path of this REST route.
+	 *
+	 * @return string
+	 */
+	public function get_path() {
+		return self::get_path_regex();
+	}
+
+	/**
+	 * Get the path of this rest route.
+	 *
+	 * @return string
+	 */
+	public static function get_path_regex() {
+		return '/catalog/search';
+	}
+
+	/**
+	 * The UCP capability this route serves.
+	 *
+	 * @return string
+	 */
+	protected function get_capability(): string {
+		return UcpUtils::CAPABILITY_CATALOG_SEARCH;
+	}
+
+	/**
+	 * Request body arguments accepted by this route.
+	 *
+	 * Bounds the UCP contract names its own error codes for (query length, price
+	 * range) stay in the handler, so the response carries those codes rather than
+	 * `rest_invalid_param`.
+	 *
+	 * @return array
+	 */
+	protected function get_request_args(): array {
+		return array_merge(
+			parent::get_request_args(),
+			array(
+				'query'      => array(
+					'description' => __( 'Free-text search query.', 'woocommerce' ),
+					'type'        => 'string',
+				),
+				'filters'    => array(
+					'description' => __( 'Filter criteria to narrow search results.', 'woocommerce' ),
+					'type'        => 'object',
+				),
+				'pagination' => array(
+					'description' => __( 'Requested page size and cursor.', 'woocommerce' ),
+					'type'        => 'object',
+				),
+			)
+		);
+	}
+
+	/**
+	 * Handle the request.
+	 *
+	 * @param \WP_REST_Request $request Request object.
+	 *
+	 * @phpstan-param \WP_REST_Request<array<string, mixed>> $request
+	 *
+	 * @return \WP_REST_Response
+	 */
+	protected function get_route_post_response( \WP_REST_Request $request ) {
+		$query      = trim( (string) $request->get_param( 'query' ) );
+		$filters    = (array) ( $request->get_param( 'filters' ) ?? array() );
+		$pagination = (array) ( $request->get_param( 'pagination' ) ?? array() );
+		$context    = $this->get_context( $request );
+
+		$invalid = $this->validate_search_input( $query, $filters );
+		if ( null !== $invalid ) {
+			return $invalid;
+		}
+
+		$limit  = isset( $pagination['limit'] ) ? min( max( (int) $pagination['limit'], 1 ), 100 ) : 10;
+		$offset = $this->decode_cursor( isset( $pagination['cursor'] ) ? (string) $pagination['cursor'] : '' );
+
+		$category_slugs = null;
+
+		if ( ! empty( $filters['categories'] ) && is_array( $filters['categories'] ) ) {
+			$category_slugs = $this->resolve_category_slugs( $filters['categories'] );
+		}
+
+		// A category list naming no existing term can match nothing; querying
+		// without the filter would return the whole catalog instead.
+		if ( null !== $category_slugs && empty( $category_slugs ) ) {
+			$results = array(
+				'objects' => array(),
+				'total'   => 0,
+			);
+		} else {
+			$results = $this->query_products( $this->store_api_params( $query, $filters, $category_slugs, $limit, $offset ) );
+		}
+
+		$mapped = $this->mapper()->map_products( $results['objects'] );
+
+		$pagination_result = array( 'has_next_page' => $offset + $limit < $results['total'] );
+
+		if ( $pagination_result['has_next_page'] ) {
+			$pagination_result['cursor'] = $this->encode_cursor( $offset + $limit );
+		}
+
+		$response = array(
+			'ucp'        => $this->ucp_metadata( 'success' ),
+			'products'   => $mapped,
+			'pagination' => $pagination_result,
+		);
+
+		$messages = $this->truncation_messages( $this->mapper()->take_truncations(), $mapped, '$.products' );
+
+		$currency_warning = $this->currency_warning( $context, $this->store_currency() );
+		if ( null !== $currency_warning ) {
+			array_unshift( $messages, $currency_warning );
+		}
+
+		if ( ! empty( $messages ) ) {
+			$response['messages'] = $messages;
+		}
+
+		return new \WP_REST_Response( $response, 200 );
+	}
+
+	/**
+	 * Validate the search query string and filters before any query runs.
+	 *
+	 * Values that would silently degrade to something meaningless — a price cast
+	 * from a non-numeric string, a range whose minimum exceeds its maximum — are
+	 * refused rather than executed, so a caller cannot spend database work on a
+	 * request that was never going to match.
+	 *
+	 * @param string $query Free-text search term.
+	 * @param array  $filters Request filters.
+	 * @return \WP_REST_Response|null Error response, or null when the input is acceptable.
+	 */
+	private function validate_search_input( string $query, array $filters ) {
+		if ( strlen( $query ) > self::MAX_QUERY_LENGTH ) {
+			return $this->ucp_error(
+				'VALIDATION_ERROR',
+				sprintf(
+					/* translators: %d: maximum accepted query length in characters. */
+					__( 'query exceeds the maximum length of %d characters.', 'woocommerce' ),
+					self::MAX_QUERY_LENGTH
+				),
+				'recoverable',
+				400
+			);
+		}
+
+		$has_categories = ! empty( $filters['categories'] ) && is_array( $filters['categories'] );
+		$has_price      = isset( $filters['price'] ) && is_array( $filters['price'] ) && ( isset( $filters['price']['min'] ) || isset( $filters['price']['max'] ) );
+
+		// UCP requires at least one recognized input; an unknown filter on its
+		// own would otherwise browse the whole catalog.
+		if ( '' === $query && ! $has_categories && ! $has_price ) {
+			return $this->ucp_error(
+				'VALIDATION_ERROR',
+				__( 'A query or at least one supported filter (categories, price) is required.', 'woocommerce' ),
+				'recoverable',
+				400
+			);
+		}
+
+		if ( ! $has_price ) {
+			return null;
+		}
+
+		$bounds = array();
+
+		foreach ( array( 'min', 'max' ) as $bound ) {
+			if ( ! isset( $filters['price'][ $bound ] ) ) {
+				continue;
+			}
+
+			$value = $filters['price'][ $bound ];
+
+			if ( ! is_numeric( $value ) || (float) $value < 0 ) {
+				return $this->ucp_error(
+					'VALIDATION_ERROR',
+					sprintf(
+						/* translators: %s: price filter bound name, `min` or `max`. */
+						__( 'filters.price.%s must be a non-negative amount in minor units.', 'woocommerce' ),
+						$bound
+					),
+					'recoverable',
+					400
+				);
+			}
+
+			$bounds[ $bound ] = (int) $value;
+		}
+
+		if ( isset( $bounds['min'], $bounds['max'] ) && $bounds['min'] > $bounds['max'] ) {
+			return $this->ucp_error(
+				'VALIDATION_ERROR',
+				__( 'filters.price.min must not exceed filters.price.max.', 'woocommerce' ),
+				'recoverable',
+				400
+			);
+		}
+
+		return null;
+	}
+
+	/**
+	 * Translate the UCP search body into Store API product collection parameters.
+	 *
+	 * Price bounds pass through in minor units for ProductQuery to convert and tax-adjust.
+	 * Like core's other price filters it matches on range overlap: a variable product
+	 * qualifies when its min–max span intersects the bounds, even with no variation inside them.
+	 *
+	 * @param string     $query Free-text search term.
+	 * @param array      $filters Request filters.
+	 * @param array|null $category_slugs Resolved category slugs, or null when none were requested.
+	 * @param int        $limit Page size.
+	 * @param int        $offset Offset of the first result.
+	 * @return array
+	 */
+	private function store_api_params( string $query, array $filters, ?array $category_slugs, int $limit, int $offset ): array {
+		$params = array(
+			'orderby'            => 'date',
+			'order'              => 'desc',
+			'per_page'           => $limit,
+			'offset'             => $offset,
+			'catalog_visibility' => $query ? CatalogVisibility::SEARCH : CatalogVisibility::CATALOG,
+		);
+
+		if ( '' !== $query ) {
+			$params['search'] = $query;
+		}
+
+		if ( ! empty( $category_slugs ) ) {
+			$params['category'] = $category_slugs;
+		}
+
+		if ( isset( $filters['price'] ) && is_array( $filters['price'] ) ) {
+			foreach ( array( 'min', 'max' ) as $bound ) {
+				if ( isset( $filters['price'][ $bound ] ) ) {
+					$params[ $bound . '_price' ] = (int) $filters['price'][ $bound ];
+				}
+			}
+		}
+
+		return $params;
+	}
+
+	/**
+	 * Resolve category references to the term slugs ProductQuery filters on.
+	 *
+	 * ProductQuery types a category list from its first element, so a request
+	 * mixing ids and slugs is normalized to slugs here. The list is deduplicated
+	 * and capped: one request must not drive an unbounded number of term lookups
+	 * on an unauthenticated endpoint.
+	 *
+	 * @param array $references Category term ids or slugs.
+	 * @return array
+	 */
+	private function resolve_category_slugs( array $references ): array {
+		$slugs = array();
+
+		foreach ( array_slice( array_unique( $references ), 0, self::MAX_CATEGORY_FILTERS ) as $reference ) {
+			$reference = (string) $reference;
+			$term      = ctype_digit( $reference )
+				? get_term_by( 'id', (int) $reference, 'product_cat' )
+				: get_term_by( 'slug', $reference, 'product_cat' );
+
+			if ( $term instanceof \WP_Term ) {
+				$slugs[] = $term->slug;
+			}
+		}
+
+		return $slugs;
+	}
+
+	/**
+	 * Run the Store API product query, recounting when the cursor is past the end.
+	 *
+	 * `found_posts` is only computed when the page returned rows, and ProductQuery's
+	 * own recount keys off `page` rather than `offset`, so an empty page at a non-zero
+	 * offset is recounted here with a single-row query from the start.
+	 *
+	 * @param array $params Store API product collection parameters.
+	 * @return array `{ objects, total }`.
+	 */
+	private function query_products( array $params ): array {
+		$product_query = new ProductQuery();
+		$results       = $product_query->get_objects( $this->store_api_request( $params ) );
+
+		if ( empty( $results['objects'] ) && ! empty( $params['offset'] ) ) {
+			$params['offset']   = 0;
+			$params['per_page'] = 1;
+			$results['total']   = $product_query->get_results( $this->store_api_request( $params ) )['total'];
+		}
+
+		return $results;
+	}
+
+	/**
+	 * Wrap collection parameters in the request object ProductQuery reads.
+	 *
+	 * @param array $params Store API product collection parameters.
+	 * @return \WP_REST_Request<array<string, mixed>>
+	 */
+	private function store_api_request( array $params ): \WP_REST_Request {
+		/**
+		 * Typed for static analysis; WP_REST_Request is generic in the stubs.
+		 *
+		 * @var \WP_REST_Request<array<string, mixed>> $request
+		 */
+		$request = new \WP_REST_Request( 'GET', '/wc/store/v1/products' );
+		$request->set_query_params( $params );
+
+		return $request;
+	}
+
+	/**
+	 * Encode a pagination offset into an opaque cursor string.
+	 *
+	 * @param int $offset Numeric offset.
+	 * @return string
+	 */
+	private function encode_cursor( int $offset ): string {
+		return base64_encode( 'offset:' . $offset ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode
+	}
+
+	/**
+	 * Decode a cursor string back into a numeric offset.
+	 *
+	 * @param string $cursor Opaque cursor string.
+	 * @return int
+	 */
+	private function decode_cursor( string $cursor ): int {
+		if ( '' === $cursor ) {
+			return 0;
+		}
+
+		$decoded = base64_decode( $cursor, true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode
+		if ( false === $decoded || 0 !== strpos( $decoded, 'offset:' ) ) {
+			return 0;
+		}
+
+		return max( 0, (int) substr( $decoded, 7 ) );
+	}
+}
diff --git a/plugins/woocommerce/src/StoreApi/RoutesController.php b/plugins/woocommerce/src/StoreApi/RoutesController.php
index 9f9eb40af1d..bd3dda1a49e 100644
--- a/plugins/woocommerce/src/StoreApi/RoutesController.php
+++ b/plugins/woocommerce/src/StoreApi/RoutesController.php
@@ -5,6 +5,7 @@ namespace Automattic\WooCommerce\StoreApi;

 use Automattic\WooCommerce\Internal\ShopperLists\ShopperListsController;
 use Automattic\WooCommerce\StoreApi\Routes\V1\AbstractRoute;
+use Automattic\WooCommerce\StoreApi\Utilities\UcpUtils;

 /**
  * RoutesController class.
@@ -82,6 +83,12 @@ class RoutesController {
 				Routes\V1\ShopperListItems::IDENTIFIER   => Routes\V1\ShopperListItems::class,
 				Routes\V1\ShopperListItemsByKey::IDENTIFIER => Routes\V1\ShopperListItemsByKey::class,
 			],
+			'ucp'           => [
+				// Universal Commerce Protocol endpoints, gated by UcpUtils::is_enabled().
+				Routes\V1\Ucp\CatalogSearch::IDENTIFIER  => Routes\V1\Ucp\CatalogSearch::class,
+				Routes\V1\Ucp\CatalogLookup::IDENTIFIER  => Routes\V1\Ucp\CatalogLookup::class,
+				Routes\V1\Ucp\CatalogProduct::IDENTIFIER => Routes\V1\Ucp\CatalogProduct::class,
+			],
 		];
 	}

@@ -96,6 +103,10 @@ class RoutesController {
 		if ( wc_get_container()->get( ShopperListsController::class )->is_enabled() ) {
 			$this->register_routes( 'shopper_lists', self::$api_namespace . '/v1' );
 		}
+
+		if ( UcpUtils::is_enabled() ) {
+			$this->register_routes( 'ucp', 'wc/ucp/v1' );
+		}
 	}

 	/**
diff --git a/plugins/woocommerce/src/StoreApi/SchemaController.php b/plugins/woocommerce/src/StoreApi/SchemaController.php
index 8705a2f15c5..574d85a0a54 100644
--- a/plugins/woocommerce/src/StoreApi/SchemaController.php
+++ b/plugins/woocommerce/src/StoreApi/SchemaController.php
@@ -59,6 +59,8 @@ class SchemaController {
 				Schemas\V1\PatternsSchema::IDENTIFIER      => Schemas\V1\PatternsSchema::class,
 				Schemas\V1\ShopperListSchema::IDENTIFIER   => Schemas\V1\ShopperListSchema::class,
 				Schemas\V1\ShopperListItemSchema::IDENTIFIER => Schemas\V1\ShopperListItemSchema::class,
+				Schemas\V1\Ucp\UcpCatalogSchema::IDENTIFIER => Schemas\V1\Ucp\UcpCatalogSchema::class,
+				Schemas\V1\Ucp\UcpProductSchema::IDENTIFIER => Schemas\V1\Ucp\UcpProductSchema::class,
 			],
 		];
 	}
diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/Ucp/UcpCatalogSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/Ucp/UcpCatalogSchema.php
new file mode 100644
index 00000000000..ebd3a734940
--- /dev/null
+++ b/plugins/woocommerce/src/StoreApi/Schemas/V1/Ucp/UcpCatalogSchema.php
@@ -0,0 +1,38 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\StoreApi\Schemas\V1\Ucp;
+
+use Automattic\WooCommerce\StoreApi\Schemas\V1\AbstractSchema;
+
+/**
+ * Placeholder schema for the UCP catalog routes.
+ *
+ * The routes answer with the UCP envelope rather than a Store API resource, so
+ * they declare no properties; this exists because every Store API route is
+ * constructed with a schema.
+ */
+class UcpCatalogSchema extends AbstractSchema {
+	/**
+	 * The schema item name.
+	 *
+	 * @var string
+	 */
+	protected $title = 'ucp-catalog';
+
+	/**
+	 * The schema item identifier.
+	 *
+	 * @var string
+	 */
+	const IDENTIFIER = 'ucp-catalog';
+
+	/**
+	 * Schema properties.
+	 *
+	 * @return array
+	 */
+	public function get_properties() {
+		return array();
+	}
+}
diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/Ucp/UcpProductSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/Ucp/UcpProductSchema.php
new file mode 100644
index 00000000000..0c02e8088b2
--- /dev/null
+++ b/plugins/woocommerce/src/StoreApi/Schemas/V1/Ucp/UcpProductSchema.php
@@ -0,0 +1,40 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\StoreApi\Schemas\V1\Ucp;
+
+use Automattic\WooCommerce\StoreApi\Schemas\V1\ProductSchema;
+
+/**
+ * Product schema whose item responses carry raw strings, for the UCP catalog routes.
+ *
+ * Agents compare titles and SKUs literally, so the storefront HTML formatter
+ * (texturized quotes, `10x20` to `10&#215;20`) would corrupt what they match on.
+ *
+ * @since 11.3.0
+ */
+class UcpProductSchema extends ProductSchema {
+	/**
+	 * The schema item name.
+	 *
+	 * @var string
+	 */
+	protected $title = 'ucp-product';
+
+	/**
+	 * The schema item identifier.
+	 *
+	 * @var string
+	 */
+	const IDENTIFIER = 'ucp-product';
+
+	/**
+	 * Return content unformatted; the UCP mapper derives plain text itself.
+	 *
+	 * @param string|array $response Data to format.
+	 * @return string|array
+	 */
+	protected function prepare_html_response( $response ) {
+		return $response;
+	}
+}
diff --git a/plugins/woocommerce/src/StoreApi/Utilities/UcpUtils.php b/plugins/woocommerce/src/StoreApi/Utilities/UcpUtils.php
new file mode 100644
index 00000000000..605715d9770
--- /dev/null
+++ b/plugins/woocommerce/src/StoreApi/Utilities/UcpUtils.php
@@ -0,0 +1,83 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\StoreApi\Utilities;
+
+use Automattic\WooCommerce\Utilities\FeaturesUtil;
+
+/**
+ * Shared helpers for the UCP (Universal Commerce Protocol) routes.
+ */
+class UcpUtils {
+	/**
+	 * UCP specification version these routes implement.
+	 */
+	const VERSION = '2026-04-08';
+
+	/**
+	 * Feature id gating the UCP routes, stored as `woocommerce_feature_ucp_enabled`.
+	 */
+	const FEATURE_ID = 'ucp';
+
+	/**
+	 * Capability served by `POST /catalog/search`.
+	 */
+	const CAPABILITY_CATALOG_SEARCH = 'dev.ucp.shopping.catalog.search';
+
+	/**
+	 * Capability served by `POST /catalog/lookup` and `POST /catalog/product`.
+	 */
+	const CAPABILITY_CATALOG_LOOKUP = 'dev.ucp.shopping.catalog.lookup';
+
+	/**
+	 * Whether the UCP routes are registered.
+	 *
+	 * @return bool
+	 */
+	public static function is_enabled(): bool {
+		return FeaturesUtil::feature_is_enabled( self::FEATURE_ID );
+	}
+
+	/**
+	 * Build the `ucp` envelope every UCP response carries.
+	 *
+	 * UCP requires `capabilities` to name only the capability relevant to the
+	 * operation being answered, so each response declares exactly one.
+	 *
+	 * @param string $capability One of the CAPABILITY_* constants.
+	 * @param string $status Response status, `success` or `error`.
+	 * @return array
+	 */
+	public static function response_metadata( string $capability, string $status ): array {
+		return array(
+			'version'      => self::VERSION,
+			'status'       => $status,
+			'capabilities' => array(
+				$capability => array( self::capability_declaration( $capability ) ),
+			),
+		);
+	}
+
+	/**
+	 * Spec and schema references for a capability.
+	 *
+	 * @param string $capability One of the CAPABILITY_* constants.
+	 * @return array
+	 */
+	private static function capability_declaration( string $capability ): array {
+		$declarations = array(
+			self::CAPABILITY_CATALOG_SEARCH => array(
+				'version' => self::VERSION,
+				'spec'    => 'https://ucp.dev/' . self::VERSION . '/specification/catalog/search',
+				'schema'  => 'https://ucp.dev/' . self::VERSION . '/schemas/shopping/catalog_search.json',
+			),
+			self::CAPABILITY_CATALOG_LOOKUP => array(
+				'version' => self::VERSION,
+				'spec'    => 'https://ucp.dev/' . self::VERSION . '/specification/catalog/lookup',
+				'schema'  => 'https://ucp.dev/' . self::VERSION . '/schemas/shopping/catalog_lookup.json',
+			),
+		);
+
+		return $declarations[ $capability ] ?? array( 'version' => self::VERSION );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/UcpCatalog.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/UcpCatalog.php
new file mode 100644
index 00000000000..c1a3f2eb58e
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/UcpCatalog.php
@@ -0,0 +1,354 @@
+<?php
+/**
+ * UCP Catalog Route Tests.
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks\StoreApi\Routes;
+
+/**
+ * Tests for the /wc/ucp/v1/catalog/* endpoints.
+ */
+class UcpCatalog extends ControllerTestCase {
+	/**
+	 * Published product IDs shared by the class, oldest first.
+	 *
+	 * @var int[]
+	 */
+	private static $product_ids = array();
+
+	/**
+	 * SKU of the newest fixture product.
+	 *
+	 * @var string
+	 */
+	private const SKU = 'ucp-catalog-sku';
+
+	/**
+	 * Create immutable fixtures before route registration starts.
+	 *
+	 * @param \WP_UnitTest_Factory $factory WordPress unit test factory.
+	 */
+	public static function wpSetUpBeforeClass( $factory ): void {
+		$products = self::create_class_fixture_products(
+			array(
+				array(
+					'name'          => 'UCP Older Product',
+					'regular_price' => '5.00',
+					'price'         => '5.00',
+				),
+				array(
+					'name'          => 'UCP Nebula Mug',
+					'regular_price' => '19.99',
+					'sale_price'    => '14.50',
+					'price'         => '14.50',
+					'sku'           => self::SKU,
+				),
+			)
+		);
+
+		self::$product_ids = array_map(
+			static function ( $product ) {
+				return $product->get_id();
+			},
+			$products
+		);
+	}
+
+	/**
+	 * Delete class fixtures.
+	 */
+	public static function wpTearDownAfterClass(): void {
+		self::delete_class_fixture_products( self::$product_ids );
+	}
+
+	/**
+	 * Option behind the `ucp` feature.
+	 */
+	private const FEATURE_OPTION = 'woocommerce_feature_ucp_enabled';
+
+	/**
+	 * Enable the UCP routes before the REST server is built.
+	 */
+	protected function setUp(): void {
+		update_option( self::FEATURE_OPTION, 'yes' );
+		parent::setUp();
+	}
+
+	/**
+	 * Restore the default disabled state.
+	 */
+	protected function tearDown(): void {
+		delete_option( self::FEATURE_OPTION );
+		parent::tearDown();
+	}
+
+	/**
+	 * Dispatch a POST request with a JSON body.
+	 *
+	 * @param string $path Route path under /wc/ucp/v1.
+	 * @param array  $body Request body.
+	 * @return \WP_REST_Response
+	 */
+	private function post( string $path, array $body = array() ) {
+		$request = new \WP_REST_Request( 'POST', '/wc/ucp/v1' . $path );
+		$request->set_header( 'content-type', 'application/json' );
+		$request->set_body( wp_json_encode( $body ) );
+
+		return rest_get_server()->dispatch( $request );
+	}
+
+	/**
+	 * The routes must not exist unless a site opts in.
+	 */
+	public function test_routes_are_not_registered_by_default(): void {
+		delete_option( self::FEATURE_OPTION );
+		$this->initialize_store_api_server();
+
+		foreach ( array( '/catalog/search', '/catalog/lookup', '/catalog/product' ) as $path ) {
+			$this->assertSame( 404, $this->post( $path )->get_status(), $path . ' should not be registered' );
+		}
+	}
+
+	/**
+	 * Search returns published products in the UCP shape, with amounts in minor units.
+	 */
+	public function test_search_returns_published_products_in_minor_units(): void {
+		$response = $this->post( '/catalog/search', array( 'query' => 'Nebula Mug' ) );
+		$data     = $response->get_data();
+
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame( '2026-04-08', $data['ucp']['version'] );
+		$this->assertSame( 'success', $data['ucp']['status'] );
+		// The envelope names only the capability relevant to the operation answered.
+		$this->assertSame( array( 'dev.ucp.shopping.catalog.search' ), array_keys( $data['ucp']['capabilities'] ) );
+		$this->assertArrayHasKey( 'has_next_page', $data['pagination'] );
+
+		$product = $this->find_product( $data['products'], end( self::$product_ids ) );
+
+		$this->assertSame( 'UCP Nebula Mug', $product['title'] );
+		$this->assertSame( 1450, $product['price_range']['min']['amount'] );
+		$this->assertSame( get_woocommerce_currency(), $product['price_range']['min']['currency'] );
+		// The regular price survives as the strike-through comparison.
+		$this->assertSame( 1999, $product['list_price_range']['min']['amount'] );
+
+		$this->assertCount( 1, $product['variants'] );
+		$this->assertSame( self::SKU, $product['variants'][0]['sku'] );
+		$this->assertSame( 1450, $product['variants'][0]['price']['amount'] );
+		$this->assertTrue( $product['variants'][0]['availability']['available'] );
+	}
+
+	/**
+	 * Price bounds are taken in minor units and applied against the stored price.
+	 */
+	public function test_search_price_filter_takes_minor_units(): void {
+		list( $older, $newest ) = self::$product_ids;
+
+		// Both fixtures carry the "UCP" prefix, so the query scopes the assertions to them.
+		$above = $this->post(
+			'/catalog/search',
+			array(
+				'query'   => 'UCP',
+				'filters' => array( 'price' => array( 'min' => 1000 ) ),
+			)
+		)->get_data();
+		$this->assertSame( array( (string) $newest ), wp_list_pluck( $above['products'], 'id' ) );
+
+		$below = $this->post(
+			'/catalog/search',
+			array(
+				'query'   => 'UCP',
+				'filters' => array( 'price' => array( 'max' => 1000 ) ),
+			)
+		)->get_data();
+		$this->assertSame( array( (string) $older ), wp_list_pluck( $below['products'], 'id' ) );
+	}
+
+	/**
+	 * Lookup resolves product IDs only; a SKU is reported as a miss like any other unknown id.
+	 */
+	public function test_lookup_resolves_ids_and_reports_misses(): void {
+		$newest   = end( self::$product_ids );
+		$response = $this->post( '/catalog/lookup', array( 'ids' => array( (string) $newest, self::SKU ) ) );
+		$data     = $response->get_data();
+
+		$this->assertSame( 200, $response->get_status() );
+
+		$this->assertCount( 1, $data['products'] );
+		$this->assertSame( (string) $newest, $data['products'][0]['id'] );
+		$this->assertSame( array( (string) $newest ), wp_list_pluck( $data['products'][0]['variants'][0]['inputs'], 'id' ) );
+
+		$this->assertSame( 'not_found', $data['messages'][0]['code'] );
+		$this->assertSame( '$.ids', $data['messages'][0]['path'] );
+	}
+
+	/**
+	 * Lookup follows the storefront's hide-out-of-stock setting.
+	 */
+	public function test_lookup_hides_out_of_stock_products_when_the_store_does(): void {
+		$product = \WC_Helper_Product::create_simple_product( true, array( 'stock_status' => 'outofstock' ) );
+		$body    = array( 'ids' => array( (string) $product->get_id() ) );
+
+		$data = $this->post( '/catalog/lookup', $body )->get_data();
+		$this->assertFalse( $data['products'][0]['variants'][0]['availability']['available'] );
+
+		update_option( 'woocommerce_hide_out_of_stock_items', 'yes' );
+		$data = $this->post( '/catalog/lookup', $body )->get_data();
+		update_option( 'woocommerce_hide_out_of_stock_items', 'no' );
+
+		$this->assertSame( array(), $data['products'] );
+		$this->assertSame( 'not_found', $data['messages'][0]['code'] );
+	}
+
+	/**
+	 * A category list resolving to no existing term matches nothing rather than everything.
+	 */
+	public function test_search_unknown_category_returns_no_products(): void {
+		$newest = end( self::$product_ids );
+		$term   = wp_insert_term( 'UCP Mugs', 'product_cat', array( 'slug' => 'ucp-mugs' ) );
+		wp_set_object_terms( $newest, (int) $term['term_id'], 'product_cat' );
+
+		$data = $this->post( '/catalog/search', array( 'filters' => array( 'categories' => array( 'ucp-mugs' ) ) ) )->get_data();
+		$this->assertSame( array( (string) $newest ), wp_list_pluck( $data['products'], 'id' ) );
+
+		$data = $this->post( '/catalog/search', array( 'filters' => array( 'categories' => array( 'does-not-exist' ) ) ) )->get_data();
+		$this->assertSame( array(), $data['products'] );
+		$this->assertFalse( $data['pagination']['has_next_page'] );
+	}
+
+	/**
+	 * Search needs a query or a supported filter; an unrecognized filter alone does not count.
+	 */
+	public function test_search_without_recognized_input_returns_ucp_validation_error(): void {
+		$response = $this->post( '/catalog/search', array( 'filters' => array( 'brand' => 'acme' ) ) );
+		$data     = $response->get_data();
+
+		$this->assertSame( 400, $response->get_status() );
+		$this->assertSame( 'error', $data['ucp']['status'] );
+		$this->assertSame( 'VALIDATION_ERROR', $data['messages'][0]['code'] );
+	}
+
+	/**
+	 * A missing `ids` list is a UCP validation error, not a WordPress one.
+	 */
+	public function test_lookup_without_ids_returns_ucp_validation_error(): void {
+		$response = $this->post( '/catalog/lookup' );
+		$data     = $response->get_data();
+
+		$this->assertSame( 400, $response->get_status() );
+		$this->assertSame( 'error', $data['ucp']['status'] );
+		$this->assertSame( 'VALIDATION_ERROR', $data['messages'][0]['code'] );
+	}
+
+	/**
+	 * Product detail answers with `ucp.status`, and reports a miss as 200 + `not_found`.
+	 */
+	public function test_product_detail_reports_status_in_the_envelope(): void {
+		$newest   = end( self::$product_ids );
+		$response = $this->post( '/catalog/product', array( 'id' => (string) $newest ) );
+		$data     = $response->get_data();
+
+		$this->assertSame( 200, $response->get_status() );
+		$this->assertSame( 'success', $data['ucp']['status'] );
+		$this->assertSame( array( 'dev.ucp.shopping.catalog.lookup' ), array_keys( $data['ucp']['capabilities'] ) );
+		$this->assertSame( (string) $newest, $data['product']['id'] );
+		$this->assertSame( 1450, $data['product']['variants'][0]['price']['amount'] );
+
+		$missing = $this->post( '/catalog/product', array( 'id' => '99999999' ) );
+		$data    = $missing->get_data();
+
+		$this->assertSame( 200, $missing->get_status(), 'A missing product is reported in the envelope, not as a 404' );
+		$this->assertSame( 'error', $data['ucp']['status'] );
+		$this->assertSame( 'not_found', $data['messages'][0]['code'] );
+		$this->assertSame( 'unrecoverable', $data['messages'][0]['severity'] );
+	}
+
+	/**
+	 * A variant id puts that variation first, takes its options as the selection,
+	 * and overrides a conflicting request selection.
+	 */
+	public function test_product_detail_by_variation_id_leads_with_that_variation(): void {
+		$children = \WC_Helper_Product::create_variation_product()->get_children();
+		// The helper's last variation: size huge, colour blue, number left as "Any".
+		$variation_id = (string) end( $children );
+
+		$data = $this->post(
+			'/catalog/product',
+			array(
+				'id'       => $variation_id,
+				'selected' => array(
+					array(
+						'name'  => 'colour',
+						'label' => 'red',
+					),
+				),
+			)
+		)->get_data();
+
+		$this->assertSame( $variation_id, $data['product']['variants'][0]['id'] );
+		$this->assertSame(
+			array(
+				array(
+					'name'  => 'size',
+					'label' => 'huge',
+				),
+				array(
+					'name'  => 'colour',
+					'label' => 'blue',
+				),
+			),
+			$data['product']['selected']
+		);
+		// The huge/blue variation with number 2 matches the same selection; nothing else does.
+		$this->assertCount( 2, $data['product']['variants'] );
+	}
+
+	/**
+	 * The opaque cursor walks forward through the result set.
+	 */
+	public function test_search_cursor_advances_to_the_next_page(): void {
+		$first = $this->post(
+			'/catalog/search',
+			array(
+				'query'      => 'UCP',
+				'pagination' => array( 'limit' => 1 ),
+			)
+		)->get_data();
+
+		$this->assertCount( 1, $first['products'] );
+		$this->assertTrue( $first['pagination']['has_next_page'] );
+		$this->assertNotEmpty( $first['pagination']['cursor'] );
+
+		$second = $this->post(
+			'/catalog/search',
+			array(
+				'query'      => 'UCP',
+				'pagination' => array(
+					'limit'  => 1,
+					'cursor' => $first['pagination']['cursor'],
+				),
+			)
+		)->get_data();
+
+		$this->assertCount( 1, $second['products'] );
+		$this->assertNotSame( $first['products'][0]['id'], $second['products'][0]['id'] );
+	}
+
+	/**
+	 * Find a mapped product by id.
+	 *
+	 * @param array $products Mapped products.
+	 * @param int   $product_id Product id to find.
+	 * @return array
+	 */
+	private function find_product( array $products, int $product_id ): array {
+		foreach ( $products as $product ) {
+			if ( (string) $product_id === $product['id'] ) {
+				return $product;
+			}
+		}
+
+		$this->fail( 'Product ' . $product_id . ' was not returned by the search' );
+	}
+}
diff --git a/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Mapping/UcpProductMapperTest.php b/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Mapping/UcpProductMapperTest.php
new file mode 100644
index 00000000000..9c64fce1939
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/ProductFeed/Mapping/UcpProductMapperTest.php
@@ -0,0 +1,325 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\ProductFeed\Mapping;
+
+use Automattic\WooCommerce\Internal\ProductFeed\Mapping\ProductShapeMapperInterface;
+use Automattic\WooCommerce\Internal\ProductFeed\Mapping\UcpProductMapper;
+use WC_Helper_Product;
+
+/**
+ * Tests for the UCP product shape mapper.
+ */
+class UcpProductMapperTest extends \WC_Unit_Test_Case {
+	/**
+	 * @testdox The mapper should be consumable through the delivery-agnostic shape contract.
+	 */
+	public function test_is_a_product_shape_mapper(): void {
+		$this->assertInstanceOf( ProductShapeMapperInterface::class, new UcpProductMapper() );
+	}
+
+	/**
+	 * @testdox Mapping a simple product should express every amount as integer minor units.
+	 */
+	public function test_maps_amounts_to_integer_minor_units(): void {
+		$product = WC_Helper_Product::create_simple_product(
+			true,
+			array(
+				'regular_price' => '19.99',
+				'sale_price'    => '14.50',
+				'price'         => '14.50',
+			)
+		);
+
+		$mapped = ( new UcpProductMapper( 'USD' ) )->map_product( $product );
+
+		$this->assertSame( 1450, $mapped['price_range']['min']['amount'] );
+		$this->assertSame( 'USD', $mapped['price_range']['min']['currency'] );
+		$this->assertSame( 1999, $mapped['list_price_range']['max']['amount'] );
+		$this->assertSame( 1450, $mapped['variants'][0]['price']['amount'] );
+	}
+
+	/**
+	 * A variable product with no enabled variation cannot satisfy the catalog schema,
+	 * which requires at least one variant, so it must be reported as ineligible.
+	 *
+	 * @testdox A variable product with no enabled variation should not be catalog-eligible.
+	 */
+	public function test_variable_product_without_enabled_variations_is_ineligible(): void {
+		$mapper   = new UcpProductMapper( 'USD' );
+		$variable = WC_Helper_Product::create_variation_product();
+
+		$this->assertTrue( $mapper->has_catalog_variants( $variable ) );
+
+		// Disabling a variation sets it private; saving through the CRUD layer
+		// invalidates the parent's cached children list.
+		foreach ( $variable->get_children() as $child_id ) {
+			$variation = wc_get_product( $child_id );
+			$variation->set_status( 'private' );
+			$variation->save();
+		}
+
+		$variable = wc_get_product( $variable->get_id() );
+
+		$this->assertFalse( $mapper->has_catalog_variants( $variable ) );
+		$this->assertSame( array(), $mapper->map_products( array( $variable ) ) );
+	}
+
+	/**
+	 * A sale price outside its scheduled window is not the effective price, so
+	 * advertising it as a list price would render a strikethrough equal to the price.
+	 *
+	 * @testdox A simple product whose sale window has passed should carry no list price.
+	 */
+	public function test_expired_sale_yields_no_list_price(): void {
+		$product = WC_Helper_Product::create_simple_product(
+			true,
+			array(
+				'regular_price'     => '72.22',
+				'sale_price'        => '45.00',
+				'date_on_sale_from' => time() - 20 * DAY_IN_SECONDS,
+				'date_on_sale_to'   => time() - 10 * DAY_IN_SECONDS,
+			)
+		);
+
+		$mapped = ( new UcpProductMapper( 'USD' ) )->map_product( $product );
+
+		$this->assertSame( 7222, $mapped['price_range']['min']['amount'] );
+		$this->assertArrayNotHasKey( 'list_price_range', $mapped );
+		$this->assertArrayNotHasKey( 'list_price', $mapped['variants'][0] );
+	}
+
+	/**
+	 * `get_variation_prices()` rewrites `sale_price` to the regular price when a sale
+	 * is not active, so only the effective `price` distinguishes a real discount.
+	 *
+	 * @testdox A variable product should carry list prices only for variations on sale now.
+	 */
+	public function test_variable_product_list_prices_follow_active_sales_only(): void {
+		$variable = new \WC_Product_Variable();
+		$variable->set_name( 'Variable Sale Windows' );
+		$variable->set_status( 'publish' );
+		$variable->set_attributes( array( WC_Helper_Product::create_product_attribute_object( 'size', array( 'small', 'medium', 'large' ) ) ) );
+		$parent_id = $variable->save();
+
+		// Active sale, sale that has expired, and sale that has not started.
+		$windows = array(
+			'small'  => array( '100', '80', null, null ),
+			'medium' => array( '200', '150', time() - 20 * DAY_IN_SECONDS, time() - 10 * DAY_IN_SECONDS ),
+			'large'  => array( '300', '250', time() + 10 * DAY_IN_SECONDS, time() + 20 * DAY_IN_SECONDS ),
+		);
+
+		foreach ( $windows as $size => $window ) {
+			$variation = new \WC_Product_Variation();
+			$variation->set_parent_id( $parent_id );
+			$variation->set_attributes( array( 'pa_size' => $size ) );
+			$variation->set_regular_price( $window[0] );
+			$variation->set_sale_price( $window[1] );
+			$variation->set_date_on_sale_from( $window[2] );
+			$variation->set_date_on_sale_to( $window[3] );
+			$variation->set_status( 'publish' );
+			$variation->save();
+		}
+
+		\WC_Product_Variable::sync( $parent_id );
+		wc_delete_product_transients( $parent_id );
+
+		$mapped = ( new UcpProductMapper( 'USD' ) )->map_product( wc_get_product( $parent_id ) );
+
+		$this->assertSame( 8000, $mapped['price_range']['min']['amount'] );
+		$this->assertSame( 30000, $mapped['price_range']['max']['amount'] );
+
+		// The range spans every regular price, because one variation is genuinely discounted.
+		$this->assertSame( 10000, $mapped['list_price_range']['min']['amount'] );
+		$this->assertSame( 30000, $mapped['list_price_range']['max']['amount'] );
+
+		$list_prices = array();
+		foreach ( $mapped['variants'] as $variant ) {
+			$list_prices[ $variant['price']['amount'] ] = $variant['list_price']['amount'] ?? null;
+		}
+
+		$this->assertSame( 10000, $list_prices[8000] );
+		$this->assertNull( $list_prices[20000] );
+		$this->assertNull( $list_prices[30000] );
+	}
+
+	/**
+	 * `description.plain` is declared plain text, so entities left in it would
+	 * reach a shopper literally as `&nbsp;` or `&amp;`.
+	 *
+	 * @testdox A plain description should be decoded and free of HTML entities.
+	 */
+	public function test_description_decodes_html_entities(): void {
+		$product = WC_Helper_Product::create_simple_product( false );
+		$product->set_short_description( 'Rich &amp; smooth &mdash; 100&nbsp;% arabica <strong>bold</strong>' );
+		$product->save();
+
+		$mapped = ( new UcpProductMapper( 'USD' ) )->map_product( $product );
+
+		$this->assertSame( "Rich & smooth \u{2014} 100 % arabica bold", $mapped['description']['plain'] );
+		$this->assertStringNotContainsString( "\u{A0}", $mapped['description']['plain'] );
+	}
+
+	/**
+	 * @testdox A description of only non-breaking spaces should map to an empty string.
+	 */
+	public function test_description_of_entities_only_is_trimmed_to_empty(): void {
+		$product = WC_Helper_Product::create_simple_product( false );
+		$product->set_short_description( "&nbsp;\r\n\r\n&nbsp;" );
+		$product->save();
+
+		$mapped = ( new UcpProductMapper( 'USD' ) )->map_product( $product );
+
+		$this->assertSame( '', $mapped['description']['plain'] );
+	}
+
+	/**
+	 * UCP requires `selected` to describe the variants returned, so an
+	 * unmatched selection is relaxed from its last option rather than echoed.
+	 *
+	 * @testdox A selection matching no variation should be relaxed and reported as relaxed.
+	 */
+	public function test_detail_selection_is_relaxed_until_a_variation_matches(): void {
+		$variable = new \WC_Product_Variable();
+		$variable->set_name( 'Relaxed Selection' );
+		$variable->set_status( 'publish' );
+		$variable->set_attributes( array( WC_Helper_Product::create_product_attribute_object( 'size', array( 'small', 'large' ) ) ) );
+		$parent_id = $variable->save();
+
+		foreach ( array( 'small', 'large' ) as $size ) {
+			$variation = new \WC_Product_Variation();
+			$variation->set_parent_id( $parent_id );
+			$variation->set_attributes( array( 'pa_size' => $size ) );
+			$variation->set_regular_price( '10' );
+			$variation->set_status( 'publish' );
+			$variation->save();
+		}
+
+		\WC_Product_Variable::sync( $parent_id );
+		$mapper  = new UcpProductMapper( 'USD' );
+		$product = wc_get_product( $parent_id );
+
+		$size  = array(
+			'name'  => 'size',
+			'label' => 'large',
+		);
+		$color = array(
+			'name'  => 'color',
+			'label' => 'red',
+		);
+
+		$mapped = $mapper->map_detail_product( $product, array( $size, $color ) );
+		$this->assertSame( array( $size ), $mapped['selected'] );
+		$this->assertCount( 1, $mapped['variants'] );
+
+		$mapped = $mapper->map_detail_product(
+			$product,
+			array(
+				array(
+					'name'  => 'size',
+					'label' => 'medium',
+				),
+			)
+		);
+		$this->assertSame( array(), $mapped['selected'] );
+		$this->assertCount( 2, $mapped['variants'] );
+	}
+
+	/**
+	 * The storefront treats an "Any" variation attribute as a wildcard and
+	 * `options` advertises every term for it, so a selection must match it too.
+	 *
+	 * @testdox A variation left as "Any" for an attribute should match every selection of it.
+	 */
+	public function test_detail_selection_matches_any_variations(): void {
+		$variable = new \WC_Product_Variable();
+		$variable->set_name( 'Any Color' );
+		$variable->set_status( 'publish' );
+		$variable->set_attributes(
+			array(
+				WC_Helper_Product::create_product_attribute_object( 'size', array( 'small', 'large' ) ),
+				WC_Helper_Product::create_product_attribute_object( 'color', array( 'red', 'blue' ) ),
+			)
+		);
+		$parent_id = $variable->save();
+
+		foreach ( array( 'small', 'large' ) as $size ) {
+			$variation = new \WC_Product_Variation();
+			$variation->set_parent_id( $parent_id );
+			$variation->set_attributes(
+				array(
+					'pa_size'  => $size,
+					'pa_color' => '',
+				)
+			);
+			$variation->set_regular_price( '10' );
+			$variation->set_status( 'publish' );
+			$variation->save();
+		}
+
+		\WC_Product_Variable::sync( $parent_id );
+
+		$selected = array(
+			array(
+				'name'  => 'color',
+				'label' => 'red',
+			),
+			array(
+				'name'  => 'size',
+				'label' => 'large',
+			),
+		);
+		$mapped   = ( new UcpProductMapper( 'USD' ) )->map_detail_product( wc_get_product( $parent_id ), $selected );
+
+		$this->assertSame( $selected, $mapped['selected'] );
+		$this->assertCount( 1, $mapped['variants'] );
+		$this->assertCount( 2, $mapped['options'][1]['values'] );
+	}
+
+	/**
+	 * Agents compare titles, SKUs and option labels literally, so the storefront
+	 * HTML formatter must not reach them and both option lists must agree.
+	 *
+	 * @testdox Mapped strings should be raw and option labels should be term names on both lists.
+	 */
+	public function test_strings_are_raw_and_option_labels_are_term_names(): void {
+		$variable = new \WC_Product_Variable();
+		$variable->set_name( 'Frame 10x20 "Classic"' );
+		$variable->set_status( 'publish' );
+		$variable->set_attributes( array( WC_Helper_Product::create_product_attribute_object( 'size', array( 'Extra Large' ) ) ) );
+		$parent_id = $variable->save();
+
+		$variation = new \WC_Product_Variation();
+		$variation->set_parent_id( $parent_id );
+		$variation->set_attributes( array( 'pa_size' => 'extra-large' ) );
+		$variation->set_regular_price( '10' );
+		$variation->set_sku( 'FRAME-10x20' );
+		$variation->set_status( 'publish' );
+		$variation->save();
+
+		\WC_Product_Variable::sync( $parent_id );
+
+		$mapped = ( new UcpProductMapper( 'USD' ) )->map_product( wc_get_product( $parent_id ) );
+
+		$this->assertSame( 'Frame 10x20 "Classic"', $mapped['title'] );
+		$this->assertSame( 'FRAME-10x20', $mapped['variants'][0]['sku'] );
+		$this->assertSame(
+			array(
+				array(
+					'name'   => 'size',
+					'values' => array( array( 'label' => 'Extra Large' ) ),
+				),
+			),
+			$mapped['options']
+		);
+		$this->assertSame(
+			array(
+				array(
+					'name'  => 'size',
+					'label' => 'Extra Large',
+				),
+			),
+			$mapped['variants'][0]['options']
+		);
+	}
+}