Shelfglance

Shopify’s agent‑commerce category filter didn’t filter on any of 190 stores

The endpoint declares a category filter in its published schema. We sent one to 200 live storefronts. Not one of them narrowed a result set by it.

The endpoint

Since 2026 every Shopify store answers an agent-commerce endpoint at POST /api/ucp/mcp, advertised at GET /.well-known/ucp, speaking the Universal Commerce Protocol. Merchants did not switch it on and it is not in their admin. It exists so an AI shopping agent can ask a store for its catalogue directly instead of scraping the HTML, and it returns clean structured data: price as an integer in minor units with a currency code, variants, SKUs, media, canonical URLs, and a Shopify taxonomy category per product.

It is a JSON-RPC tools/call against a tool named search_catalog. The interesting argument is catalog, and inside it, filters.

What the schema says

Fetch the tool list from any store and search_catalog declares, among others, catalog.filters.categories — an array of strings, documented as “category filters combined with OR logic” — alongside catalog.filters.price.{min,max} as integers in minor currency units.

So an agent is plainly meant to be able to say show me your running shoes and get running shoes. We were about to write a paragraph about what it costs a merchant to leave the category field blank, given that. Then we tried it.

Method

200 stores, drawn deterministically from a corpus of 10,099 known Shopify storefronts — sort the hostnames, take every Nth — so the sample is reproducible and nobody has to take “we picked 200 stores” on trust. Run on 2 September 2026. Each store got the same sequence of calls, 10 products requested every time:

#CallFilter sentWhat a working filter does
1Control nonereturns products
2Impossible category gid://shopify/TaxonomyCategory/zz-99-99-99returns nothing — no product is in it
3The store’s own category a category the control’s own products carryreturns something — at least the product we took it from
4Same, unwrapped the bare id without gid://…the other form an agent would reasonably try
5Price control price.max = 1returns nothing — nothing costs a cent

Calls 2 and 3 are the experiment and they are only meaningful together. An earlier four-store version of this ran call 2 alone, and call 2 alone cannot tell the filter is ignored from the filter rejects everything. Those are opposite findings and, as it turns out, both happen.

The query matters more than it looks. A first attempt used generic words — “gift”, “set”, “new” — and produced numbers that were measured honestly and were still wrong: a bad query surfaces a catalogue’s odd corners rather than its catalogue. Every query here is three words taken from one of the store’s own product titles, read from its public /products.json.

Result

190 stores answered

What the store did with filters.categoriesStores
Ignored it — the impossible category returned the full unfiltered set186
Rejected everything — returned zero for every value, including the category its own products carry4
Filtered correctly — impossible category empty, own category not0

Of the 200 sampled, 10 could not be read: eight served no product feed to build a query from, two matched nothing for their own product title.

177 of the 190 returned at least one product carrying a taxonomy category, so this is not a story about missing data. The category is there. The filter does not use it.

The control, which is the whole reason this is publishable

A null result is worth nothing unless you can show the request worked. Ours is price.max, sent in the same envelope to the same store with one field changed.

One field in filters moves the result and the other does not, in the same call, on the same store. That is the finding. If nothing had moved, the correct conclusion would have been that we were sending it wrong.

The mistake we made first

The first full run of this reported 177 stores refusing the request outright, and it was about to be written up as a discovery. It was our bug.

A product comes back with categories: [{"value": "gid://shopify/TaxonomyCategory/hb-3-2-1-1", "taxonomy": "shopify"}] — objects. filters.categories is declared as an array of strings. We were passing the object straight back in, violating the endpoint’s own schema, and calling its entirely correct refusal a finding about Shopify.

Taking .value fixed it, and call 4 above exists because of it: if neither the wrapped identifier nor the bare one narrows anything, “you sent it wrong” stops being available as an explanation. We are describing this at length because it is the exact failure this kind of post usually ships with, and the only defence against it is publishing the rows.

What this does and does not mean

It does not mean an uncategorised product is invisible to agents. That was the sentence we expected to be able to write and it is not true — nothing is being filtered out of anything, so a blank category excludes you from nothing.

It does mean an agent cannot narrow a catalogue search by category today, whatever the schema says, on any of the 190 stores we could read.

It is not a complaint about Shopify. UCP shipped in 2026. A schema arriving before every part of its behaviour does is ordinary, and this is a young protocol doing a hard thing. The useful lesson is narrower and older than agentic commerce: test what an endpoint does rather than reading what it declares.

Reproduce it

One store, one call, no tooling. Pick any Shopify domain:

curl -s https://EXAMPLE.com/api/ucp/mcp \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{
        "name":"search_catalog",
        "arguments":{"catalog":{"query":"YOUR QUERY","filters":{
          "categories":["gid://shopify/TaxonomyCategory/zz-99-99-99"]}}}}}'

Run it with and without the filters block and compare the counts. Note that calling the endpoint requires serving a UCP platform profile the store can fetch; ours is at /ucp/agent-profile.json and declares empty payment_handlers, because a scanner takes no payments.

All 190 readings, one row per store: filter-survey.csv — store, query, products returned for each of the five calls, and the verdict. Every row names a domain you can re-run yourself, which is the point of publishing it.

Who ran this

Shelfglance, which measures what AI assistants can read from Shopify storefronts and keeps a public directory of 10,099 of them. This study came out of trying to write an honest sentence about what a blank category field costs a merchant, discovering the obvious sentence was false, and deciding to publish the reason rather than the sentence. The merchant-facing version of the same finding is here.