The Products File: Catalog Format Reference
The Basic Shape
A minimal catalog is a currency code and one product:
{
"currency": "usd",
"products": [
{
"id": "pro-license",
"title": "Pro License",
"description": "Full license with all features unlocked.",
"url": "https://example.com/products/pro-license",
"image_url": "https://example.com/img/pro-license.png",
"price": 4999,
"available": true
}
]
}
The currency is a three-letter ISO 4217 code, lowercase recommended, and it applies to the whole catalog, one currency per store. The price 4999 is 49.99 dollars expressed in integer minor units (cents). This is deliberate and non-negotiable in the schema: integer cents match how Stripe represents amounts and eliminate the entire class of floating point rounding bugs that appear the moment money is stored as 49.99. The module formats human-readable amounts itself when it builds totals for display.
Product Fields
id (required, string). The stable identifier agents and checkouts use to buy the product. Choose ids you are comfortable seeing in URLs, logs, and order payloads, kebab-case slugs like pro-license or credits-1000 work well. Ids must be unique, and uniqueness is global across products and variants together, explained below.
title (required, string). The display name shown in carts, checkout sessions, Stripe line items, and the product feed. Keep it self-describing, the agent will quote it to the shopper.
description (optional, string, defaults to empty). Searched by the search_products tool along with the title and id, so a good description directly improves how reliably agents find the right product. Write it like the one paragraph a salesperson would say.
price (required, non-negative integer). The price in minor units. For products with variants this acts as the base or display price, the charged amount always comes from the chosen variant.
available (optional, boolean, defaults to true). Setting it to false keeps the product visible in the catalog file but blocks purchasing: cart adds fail with an out-of-stock error, existing checkout lines for it are dropped with a buyer-visible warning, and the product feed marks it out_of_stock. This is the right switch for pausing sales without deleting history.
url and image_url (optional, valid URLs). A canonical product page and a product image. The cart and checkout flows work without them, but the ACP product feed requires both for a row to be publishable, the feed generator skips products that lack them and logs which fields were missing.
Variants
A product with sizes, tiers, or seat counts uses variants:
{
"id": "pro-license",
"title": "Pro License",
"description": "Full license with all features unlocked.",
"price": 4999,
"available": true,
"variants": [
{ "id": "pro-single", "title": "Single seat", "price": 4999 },
{ "id": "pro-team", "title": "Team (5 seats)", "price": 19999 },
{ "id": "pro-site", "title": "Site license", "price": 49999,
"attributes": { "seats": "unlimited" } }
]
}
Each variant has its own required id, title, and integer price, plus an optional attributes map of string key-value pairs for things like size or seat count. The buying rule is strict and symmetric: a product with variants must be bought by variant id, and attempting to buy it by product id returns a validation error naming the problem. A product without variants is bought by its own id. In cart and checkout line items, variant purchases display as a combined title, Pro License (Team (5 seats)) style, so the shopper always sees exactly what they chose.
Validation: What the Loader Enforces
The catalog is validated with a strict schema every time it loads, and a catalog that fails validation is rejected whole, the module never serves a partially valid store. The checks: currency must be exactly three characters, every product needs a non-empty id and title, prices must be non-negative integers (a price of 19.99 fails loudly instead of rounding silently), url and image_url must parse as URLs when present, and the global id uniqueness rule runs across products and variants. Because validation runs on load rather than on purchase, a bad edit surfaces the moment the file is read, not the first time a customer tries to buy.
Unknown fields on a product are intentionally allowed and passed through untouched. That is how feed-level merchandising data rides along: add brand, gtin, or other feed columns directly to a product and the feed serializer picks them up, while the cart and checkout logic ignores them. Your catalog file can therefore be the one place that describes a product for both purchasing and discovery.
Where the File Lives
The catalog is read through a small source interface with three shipped options. The bundled source reads a local file path, the natural choice when the catalog deploys with the code. The remote URL source fetches it over HTTPS from a location you control, useful when a CMS or admin app publishes the file. The S3 source reads an object from a bucket, the standard pairing for Lambda deployments. All three validate identically and all three cache: the bundled source re-reads at most as often as you allow (and can re-read every time during development by setting the TTL to zero), while the remote and S3 sources default to a five minute cache. Updating your store is therefore just publishing a new file, within the cache window every new cart read, checkout, and feed request prices against the new catalog. Operational details and selection guidance are in storage and catalog drivers.
Why Catalog-Only Design Matters
Keeping the products file free of orders and configuration is a security and operations decision, not just tidiness. Because the file is the only price authority, the module can re-derive every cart line and checkout line from it on every read, which means a tampered cart row in storage, a stale price in a long-lived session, or a malicious client-supplied amount all get corrected automatically before money moves, the mechanism is described in the security model. And because the file contains nothing sensitive, it can be reviewed in pull requests, cached aggressively, served from a public bucket, and regenerated by scripts without ceremony.
A practical workflow that scales well: keep the catalog in version control next to your code, generate it from your billing system if products already live there, and let deploys publish it to wherever your catalog source reads from. Sellers thinking through what belongs in the catalog, bundles, tiers, or single products, will find the merchandising side covered in our guides to pricing digital products and bundles and upsells.
Worked Example: A Small Software Store
A typical MCP tool store sells a license, credits, and a service add-on. That catalog looks like this:
{
"currency": "usd",
"products": [
{
"id": "pro-license",
"title": "Pro License",
"description": "Unlocks all professional features for one year.",
"url": "https://yourtool.com/pro",
"image_url": "https://yourtool.com/img/pro.png",
"price": 9900,
"variants": [
{ "id": "pro-1seat", "title": "1 seat", "price": 9900 },
{ "id": "pro-5seat", "title": "5 seats", "price": 39900 }
]
},
{
"id": "credits-1000",
"title": "1,000 API Credits",
"description": "Credit pack for API usage. Credits never expire.",
"url": "https://yourtool.com/credits",
"image_url": "https://yourtool.com/img/credits.png",
"price": 1900
},
{
"id": "onboarding",
"title": "Guided Onboarding Session",
"description": "A one hour setup session with our team.",
"url": "https://yourtool.com/onboarding",
"image_url": "https://yourtool.com/img/onboarding.png",
"price": 14900,
"available": true
}
]
}
An agent buying for a customer would call add_to_cart with product_id "pro-license" and variant_id "pro-5seat", then product_id "credits-1000" with no variant. The ACP checkout would address the same purchases as sellable ids pro-5seat and credits-1000. One file, both surfaces, identical prices.
