Skip to content

feat: get product operation for catalog.lookup#195

Open
igrigorik wants to merge 25 commits intomainfrom
feat/catalog-get-product
Open

feat: get product operation for catalog.lookup#195
igrigorik wants to merge 25 commits intomainfrom
feat/catalog-get-product

Conversation

@igrigorik
Copy link
Contributor

search_catalog and lookup_catalog are 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_product is a single-resource operation (part of dev.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/product
MCP: get_product tool

Example request

{
  "id": "prod_abc123",
  "selected": [
    { "name": "Color", "label": "Blue" }
  ],
  "preferences": ["Color", "Size"],
  "context": { "country": "US" }
}

Only id is required. selected and preferences are 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

  1. Agent calls get_product(id: "prod_abc123") — no selections. Server returns the product with featured variant and option map.
  2. User picks Color=Blue. Agent calls get_product(id: "prod_abc123", selected: [{name: "Color", label: "Blue"}]). Response narrows: product.selected confirms Blue, variants are all Blue, availability on Size values updates to reflect Blue inventory.
  3. User picks Size=10. Agent adds to selections. Response returns the exact Blue/10 variant — price, SKU, availability — ready for checkout.
  4. User picks an impossible combination. Agent sends selected: [{Color: Red}, {Size: 15}] with preferences: ["Color", "Size"]. No Red/15 exists. Server relaxes from the end of preferences — drops Size, keeps Color. Response product.selected is [{Color: Red}]. Agent diffs request vs response selected, 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.selected is the response anchor. It determines the featured variant, the variant subset, and all availability signals. One concept, not three.
  • All returned variants match product.selected. No "adjacent" or "contextually relevant" variants outside the selection. Availability context for non-matching options is carried by options[].values[].available/exists, not the variant array.
  • selected_optionsoptions on variants. Separates variant identity (what a variant is) from user selection state (what the user chose). These were previously conflated under the same name.
  • input correlation moved to lookup_variant. Correlation is a batch-lookup concern, not intrinsic to variants. Base variant type stays clean; operation-specific extensions via allOf.
  • Singular response (product) not array. Single-resource semantics — not found is an error (404 / -32602), not an empty result.

Checklist

  • New feature (non-breaking change which adds functionality)
  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings

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&region=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
@igrigorik igrigorik added this to the Working Draft milestone Feb 20, 2026
@igrigorik igrigorik self-assigned this Feb 20, 2026
@igrigorik igrigorik added the TC review Ready for TC review label Feb 20, 2026
@igrigorik igrigorik changed the title Feat: get product operation for catalog.lookup feat: get product operation for catalog.lookup Feb 20, 2026

#### Product Not Found

When the identifier does not resolve to a product, return HTTP 404:

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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": {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@amithanda
Copy link
Contributor

amithanda commented Mar 1, 2026

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?
Should we add return policy for the product?

Why:

  • In addition to Product price, variants and availablity (which we are addressing currently), shipping and returns are the other important signals for user's purchase considerations which are useful to be shown on the PDP.

@alex-jansen
Copy link

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.",
Copy link

@gsmith85 gsmith85 Mar 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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": {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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."
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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": {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Base automatically changed from feat/catalog-capability to main March 8, 2026 05:08
@igrigorik igrigorik requested review from a team as code owners March 8, 2026 05:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

TC review Ready for TC review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants