feat: get product operation for catalog.lookup#195
Conversation
Introduces `dev.ucp.shopping.catalog` capability enabling platforms to search
business product catalog and perform targeted product+variant lookups.
Checkout capability assumes the platform already knows what item to buy (via
variant ID). Catalog capability fills the discovery gap—enabling scenarios like
"find me blue running shoes under $150" that lead to cart building and checkout.
Product (catalog entry)
├─ id, title, description, url, category
├─ price: PriceRange (min/max across variants)
├─ media[]: images, videos, 3D models (first = featured)
├─ options[]: dimensions like Size, Color
├─ variants[]: purchasable SKUs (first = featured)
├─ rating: aggregate reviews
└─ metadata: merchant-defined data
Variant (purchasable SKU)
├─ id: used as item.id in checkout
├─ sku, barcode: inventory identifiers
├─ title: "Blue / Large"
├─ price: Price (amount + currency in minor units)
├─ availability: { available: bool }
├─ selected_options[]: option values for this variant
├─ media[], rating, tags, metadata
└─ seller: optional marketplace context
- Free-text query with semantic search support
- Filters: category (string), price (min/max in minor units)
- Context: country, region, postal_code, intent
- Cursor-based pagination (default 10, max 25)
- Accepts product ID OR variant ID
- Always returns parent product with context
- Product ID → variants MAY be representative set
- Variant ID → variants contains only requested variant
- NOT_FOUND returns HTTP 200 with error message (not 404)
Location and market context unified into reusable types/context.json:
{
"country": "US", // ISO 3166-1 alpha-2
"region": "CA", // State/province
"postal_code": "..." // ZIP/postal
}
Catalog extends with 'intent' for semantic search hints.
REST:
POST /catalog/search → search_catalog
GET /catalog/item/{id} → get_catalog_item
MCP (JSON-RPC):
search_catalog
get_catalog_item
Inline object definitions in search_request.filters weren't rendered
in generated docs (showed as plain "object" without properties).
Fix by extracting to referenceable schemas:
- search_filters.json: category + price filter definitions
- price_filter.json: min/max integer bounds (distinct from price_range
which uses full Price objects with currency)
- dev.ucp.shopping.catalog.search - dev.ucp.shopping.catalog.lookup Docs: - Restructure to catalog/ directory - index.md: shared concepts (Product, Variant, Price, Messages) - search.md, lookup.md: individual capability docs - rest.md, mcp.md: transport bindings
Add `language` to shared Context type for requesting localized content.
Uses IETF BCP 47 language tags (same format as HTTP Accept-Language).
Key design points:
- For REST, platforms SHOULD fall back to Accept-Language header when
field is absent; when provided, context.language overrides header
- Same provisional hint pattern: businesses MAY return different
language if requested language unavailable
- Shared across catalog, checkout, cart (applies to buyer journey)
Support both endpoints for consistency with cart/checkout:
- GET /catalog/item/{id}?country=US®ion=CA&language=es - location context
via query params, language overrides Accept-Language header
- POST /catalog/lookup - full context in body (including sensitive `intent`)
Context is extensible, but only a well-known subset (country, region,
postal_code, language) is supported via GET query params. POST supports
the complete context object.
Also normalizes error codes to lowercase snake_case for consistency with
checkout (NOT_FOUND → not_found, DELAYED_FULFILLMENT → delayed_fulfillment).
Context security (catalog, checkout, schema): - Clarify context signals are provisional hints—not authorization - Enforcement MUST occur at checkout with authoritative data - May be ignored if inconsistent with stronger signals (fraud rules, export controls, authenticated account) Currency support: - Add optional `currency` field to context for multi-currency markets - Supports scenarios like Switzerland (CHF default, EUR preference) - Added to REST GET query params and schema Security: - Add HTML sanitization guidance to product/variant descriptions - Platforms MUST strip scripts, event handlers, untrusted elements Schema refinements: - rating.json: add `scale_min` with default: 1 - price_filter.json: clarify context→currency determination - product.json: improve `handle` description for SEO vs API usage Consistency: - Error codes normalized to lowercase snake_case (not_found) - Fix invalid product example (missing description, price) - Update media/variants ordering language
Product.price → Product.price_range Product.list_price → Product.list_price_range The fields reference PriceRange schema (min/max), so naming should reflect this. Variant.price and Variant.list_price remain unchanged as they reference single Price (not range).
Introduce category.json schema to support multiple product
taxonomies (google_product_category, shopify, merchant).
Schema changes:
- New: category.json with {value, taxonomy} structure
- Product: category string → category[] (Category[])
- Variant: category string → category[] (Category[])
Allow batch (multi ID) lookup support. Enables single and multi item lookup and aligns request and response shapes to search. Discussed at TC, agreed on POST-only: - Keeps request/response modeling symmetric (context in body) - Avoids special-casing query params for extensible context fields - Simplifies overall protocol surface - Single item GET can be added later if necessary Updated API: - REST: `POST /catalog/lookup` accepts `ids` array + `context` object - MCP: `catalog.ids` inside catalog object (matches search pattern) - Response returns `products` array (symmetric with search) Identifier flexibility: - MUST support product ID and variant ID - MAY support secondary identifiers (SKU, handle, etc.) - Secondary identifiers must be fields on returned product object - Client correlates results by matching fields (no guaranteed order)
Product.category is already an array of {value, taxonomy} objects, but
the search filter only accepted a single string — making it impossible
to filter by multiple categories in one request. Align the filter type
with the product field: accept an array of strings with OR semantics.
Batch lookup accepts N identifiers and returns M grouped products.
Without explicit correlation, clients must reverse-engineer which
request identifiers mapped to which response variants by field-matching
(comparing returned IDs, SKUs, handles against what was sent). This is
brittle, ambiguous for secondary identifiers, and silent about match
semantics.
Add an `input` array to each variant in lookup responses. Each entry
carries the originating request identifier (`id`, required) and an
optional `match` type indicating resolution semantics:
- Product ID in, featured variant out → match: "featured"
(server selects a representative variant)
- Variant ID in, exact variant out → match: "exact"
(input directly identifies this variant)
- Multiple inputs resolve to same product → one product, each
matched variant carries its own input entries
- Mixed product + variant IDs → both match types coexist in
the same response
---
SUPPORTED IDENTIFIERS
R1. MUST support product ID and variant ID; MAY support secondary (SKU, handle)
R2. Duplicate IDs → MUST deduplicate
R3. One ID → multiple products: return matches, MAY limit result set
R4. Multiple IDs → same product: MUST return once
CLIENT CORRELATION
R5. Response does not guarantee order
R6. Each variant carries input[] identifying which request IDs resolved to it
R7. Multiple IDs → same variant: one entry per ID, each with own match type
R8. Variants without input[] MUST NOT appear in lookup responses
RESOLUTION BEHAVIOR
R9. exact: identifier resolved directly to this variant (variant-level)
R10. featured: identifier resolved to parent product, server picked variant (product-level)
---
A. Simple product ID — ["prod_abc"]
R1 → product ID supported ✓
R10 → product-level resolution → featured
R8 → variant must have input
→ 1 product, 1 variant
variant.input: [{id: "prod_abc", match: "featured"}]
B. Simple variant ID — ["var_xyz"]
R1 → variant ID supported ✓
R9 → variant-level resolution → exact
R8 → variant must have input
→ 1 product, 1 variant
variant.input: [{id: "var_xyz", match: "exact"}]
C. Two variant IDs, same product — ["var_abc_10", "var_abc_11"]
R1 → variant IDs supported ✓
R4 → same product → returned once
R9 → both variant-level → exact
R8 → each variant must have input
→ 1 product, 2 variants
var_abc_10.input: [{id: "var_abc_10", match: "exact"}]
var_abc_11.input: [{id: "var_abc_11", match: "exact"}]
D. Product ID + its own variant ID (overlap) — ["prod_abc", "var_abc_10"]
R4 → same product → returned once
R10 → prod_abc is product-level → featured variant
R9 → var_abc_10 is variant-level → exact
If featured ≠ var_abc_10:
→ 1 product, 2 variants
featured.input: [{id: "prod_abc", match: "featured"}]
var_abc_10.input: [{id: "var_abc_10", match: "exact"}]
If featured = var_abc_10:
R7 → multiple IDs resolve to same variant, one entry per ID
→ 1 product, 1 variant
var_abc_10.input: [
{id: "var_abc_10", match: "exact"},
{id: "prod_abc", match: "featured"}
]
E. SKU shared across products — ["SKU-SHARED"]
R1 → SKU MAY be supported
R3 → matches multiple products → return matches, MAY limit
R9 → SKU resolves directly to variant → exact
→ N products (server's discretion on N), each with 1 variant
each variant.input: [{id: "SKU-SHARED", match: "exact"}]
F. Handle (product-level) — ["blue-runner-pro"]
R1 → handle MAY be supported
R10 → handle resolves to product → featured
→ 1 product, 1 variant
variant.input: [{id: "blue-runner-pro", match: "featured"}]
G. Duplicate identifiers — ["prod_abc", "prod_abc"]
R2 → deduplicated to ["prod_abc"]
→ then Scenario A
→ 1 product, 1 variant
variant.input: [{id: "prod_abc", match: "featured"}]
H. Mixed batch — ["prod_abc", "var_xyz"] (different products)
R1 → both supported ✓
R10 → prod_abc is product-level → featured
R9 → var_xyz is variant-level → exact
→ 2 products
prod_abc's variant.input: [{id: "prod_abc", match: "featured"}]
var_xyz's variant.input: [{id: "var_xyz", match: "exact"}]
I. Not found — ["prod_nonexistent"]
R1 → product ID supported ✓
→ no match → product not in response
→ response MAY include not_found message (per index.md)
→ 0 products, optional message
The signatures commit (0426800) replaced the single request_signature header with the RFC 9421 trio (Signature, Signature-Input, Content-Digest) across all endpoints.
method_fields only parses OpenAPI format (paths → operationId). The catalog MCP binding was the only caller passing an OpenRPC file, which uses a different structure (methods[].name). This produced a build failure: "Operation ID search_catalog not found". Replace with extension_schema_fields pointing at the underlying JSON schemas directly, matching the pattern used by cart-mcp.md.
Drop the maximum: 25 constraint on pagination.limit — businesses set their own upper bound. Add Page Size section to search.md defining the contract: limit is a request not a guarantee, implementations SHOULD accept at least 10, and MAY clamp silently when the request exceeds their maximum.
Strict enums in response schemas are a forward-compatibility trap: adding a new value breaks older clients whose validators reject unrecognized strings. match is server-controlled and will likely grow as resolution strategies evolve (e.g., fuzzy, semantic matching). Switch to open string with well-known values documented in description and examples array. Businesses can implement additional resolution strategies without requiring a schema version bump.
Add a dedicated single-product retrieval operation (get_product / POST /catalog/product) to the Catalog Lookup capability, separating two fundamentally different access patterns: - lookup_catalog: batch identifier resolution (cart validation, wishlists, list display) - get_product: single-product detail with interactive variant narrowing (PDP rendering) The core addition is interactive option selection via `selected` and `preferences` parameters, modeling the standard product detail page interaction where a user progressively picks options (Color, Size) and the UI updates availability in real time. Option selection: - `selected`: partial option selections the user has made so far - `preferences`: relaxation priority order — when no variant matches all selections, the server drops options from the end of this list first, preserving higher-priority choices - Response always includes `product.selected`: the effective option selections that determine the featured variant, the variant subset, and all availability signals - Clients detect relaxation by diffing their request against product.selected Availability signals on option values (relative to product.selected): - available=true, exists=true → purchasable (selectable) - available=false, exists=true → out of stock (disabled/strikethrough) - available=false, exists=false → no variant for this combination (hidden) Variant semantics: - All returned variants match product.selected — this is the filtering anchor, not just the availability anchor - Rename variant `selected_options` → `options` to separate variant identity (what a variant IS) from user selection state (what the user CHOSE) - Move `input` correlation from base Variant into operation-specific `lookup_variant` extension via allOf, since correlation is a lookup concern not intrinsic to variants
|
|
||
| #### Product Not Found | ||
|
|
||
| When the identifier does not resolve to a product, return HTTP 404: |
There was a problem hiding this comment.
REST docs specify POST /catalog/product returns HTTP 404 for not found, but rest.openapi.json only declares a 200 response for this operation (no 404, no 400 for invalid selections/preferences, no 401/403).
Suggested fix: update OpenAPI responses for /catalog/product to include at least 400, 401, 403, 404, and reference the standard error schema.
| | **Batch Lookup** | `lookup_catalog` / `POST /catalog/lookup` | Retrieve multiple products by identifier. | | ||
| | **Get Product** | `get_product` / `POST /catalog/product` | Retrieve full detail for a single product. | | ||
|
|
||
| Both operations accept product and variant identifiers. They differ in cardinality |
There was a problem hiding this comment.
The “Both operations accept product and variant identifiers” table implies get_product supports the same identifier surface as lookup_catalog (SKU, URL, handle, etc.), but the later Get Product → Supported Identifiers section says “The id parameter accepts a single product ID or variant ID.” These are materially different guarantees.
Suggested fix: explicitly state:
“MUST support product ID + variant ID”
“MAY support SKU/URL/handle/etc.” (list optional identifiers)
and add a normative note: identifiers are opaque strings and MUST NOT be dereferenced or fetched as URLs.
| "type": "object", | ||
| "allOf": [{ "$ref": "types/product.json" }], | ||
| "properties": { | ||
| "selected": { |
There was a problem hiding this comment.
detail_product.selected is optional and the description says “Present when the request includes selected options,” but the docs in lookup.md say the response MUST include product.selected and that it determines featured variant + availability anchor.
|
Should we add Shipping speed, price & options (e.g pickup in store, delivery etc) to the response, if location related buyer user context is provided? Why:
|
|
Note that #222 is also relevant in the context of catalog lookups as part of important regulatory product information that should be returned in product lookups. |
| }, | ||
| "get_product_request": { | ||
| "type": "object", | ||
| "description": "Request body for single-product retrieval with optional option selection.", |
There was a problem hiding this comment.
Nit: "variant option selection"? Or maybe just drop the terminating fragment altogether, I think the API is self explanatory.
| }, | ||
| "description": "Partial or full option selections for interactive variant narrowing. When provided, response option values include availability signals (available, exists) relative to these selections." | ||
| }, | ||
| "preferences": { |
There was a problem hiding this comment.
Question for the TC: As we think about the long-term scalability of get_product, how do we envision this relaxation logic behaving in multi-seller or marketplace scenarios?
Currently, since seller is metadata on a variant and not a first-class option, it cannot participate in the preferences stack. This forces the server to guess when inventory conflicts arise:
- Does it relax the attribute (show the wrong color to keep a preferred seller) or
- Relax the merchant (keep the color but switch to a non-Prime, higher-priced seller)?
To support heterogeneous inventory environments (where multiple sellers offer the same SKU with varying fulfillment terms), we should consider if 'Seller' or 'Fulfillment' needs to be a dimension the Agent can actually prioritize. Otherwise, the relaxation logic remains 'merchant-blind,' potentially dropping a user's primary intent (e.g., Prime eligibility, a specific vendor in B2B procurement) to satisfy a secondary one (e.g., color).
Should 'Seller' somehow be elevated to participate in the preferences stack, or should we handle marketplace-ranking logic through a more robust context object?
| "properties": { | ||
| "id": { | ||
| "type": "string", | ||
| "description": "Product or variant identifier. Implementations MUST support product ID and variant ID." |
There was a problem hiding this comment.
Suggestion: Split out separate IDs for product and variant. Using a single id for both may be brittle. As proposed, if an agent provides a variant id that is stale or purged, the server loses the anchor to the parent product and is forced to return a 404, even if the product itself is still active.
With both product_id and variant_id available you have an option for "soft recovery": If the server doesn't recognize the variant_id it can anchor on the product and still respect preferences to find the next best match.
Lower priority, but decoupling provides a clear signal to the server on the ID type being received, reducing lookups to determine if an identifier is a product or a variant.
| "type": "string", | ||
| "description": "Product or variant identifier. Implementations MUST support product ID and variant ID." | ||
| }, | ||
| "selected": { |
There was a problem hiding this comment.
Suggestion: Document how a conflict between a specific variant and selected is handled if the options aren't aligned. I.e. if variant has option Color:Red but selected is Color:Blue.
search_catalogandlookup_catalogare discovery operations — they return multiple products with featured variant(s). But once a user picks a product, the agent needs a different interaction: full product detail with all options, real-time availability as the user selects options (Color, Size), and the exact variant to purchase. This is the product detail page (PDP) flow, which is best modelled as a distinct operation — this pattern conforms to common API shapes in the wild.get_productis a single-resource operation (part ofdev.ucp.shopping.catalog.lookup) for servicing purchase flow decisions. It returns one product with a relevant subset of variants, option-level availability signals, and support for interactive variant narrowing.REST:
POST /catalog/productMCP:
get_producttoolExample request
{ "id": "prod_abc123", "selected": [ { "name": "Color", "label": "Blue" } ], "preferences": ["Color", "Size"], "context": { "country": "US" } }Only
idis required.selectedandpreferencesare for interactive narrowing.Example (redacted) response
{ "product": { "id": "prod_abc123", "title": "Runner Pro", "price_range": { "min": { "amount": 12000, "currency": "USD" }, "max": { "amount": 15000, "currency": "USD" } }, "options": [ { "name": "Color", "values": [ { "label": "Blue", "available": true, "exists": true }, { "label": "Green", "available": false, "exists": true } ] }, { "name": "Size", "values": [ { "label": "10", "available": true, "exists": true }, { "label": "11", "available": false, "exists": false } ] } ], "selected": [{ "name": "Color", "label": "Blue" }], "variants": [ { "id": "var_abc123_blue_10", "sku": "RP-BLU-10", "title": "Blue / Size 10", "price": { "amount": 12000, "currency": "USD" }, "availability": { "available": true }, "options": [ { "name": "Color", "label": "Blue" }, { "name": "Size", "label": "10" } ], "media": [{ "type": "image", "url": "https://cdn.example.com/runner-pro-blue.jpg" }] } ] } }Iterative Flow
get_product(id: "prod_abc123")— no selections. Server returns the product with featured variant and option map.get_product(id: "prod_abc123", selected: [{name: "Color", label: "Blue"}]). Response narrows:product.selectedconfirms Blue, variants are all Blue, availability on Size values updates to reflect Blue inventory.selected: [{Color: Red}, {Size: 15}]withpreferences: ["Color", "Size"]. No Red/15 exists. Server relaxes from the end ofpreferences— drops Size, keeps Color. Responseproduct.selectedis[{Color: Red}]. Agent diffs request vs responseselected, sees Size was dropped, and can surface that to the user.Each round-trip is stateless. The agent sends the full selection state, the server returns the full product state.
Key Design Decisions
product.selectedis the response anchor. It determines the featured variant, the variant subset, and all availability signals. One concept, not three.product.selected. No "adjacent" or "contextually relevant" variants outside the selection. Availability context for non-matching options is carried byoptions[].values[].available/exists, not the variant array.selected_options→optionson variants. Separates variant identity (what a variant is) from user selection state (what the user chose). These were previously conflated under the same name.inputcorrelation moved tolookup_variant. Correlation is a batch-lookup concern, not intrinsic to variants. Base variant type stays clean; operation-specific extensions viaallOf.product) not array. Single-resource semantics — not found is an error (404 /-32602), not an empty result.Checklist