Since 2026 every Shopify storefront answers an agent-commerce endpoint at
POST /api/ucp/mcp, advertised at GET /.well-known/ucp. Merchants did not
turn it on and it is not in their admin. It speaks the Universal Commerce
Protocol over JSON-RPC, and the tool that matters is search_catalog: an AI
shopping agent asks a store for its catalogue and gets structured product data
back - integer prices in minor units with a currency, variants, SKUs, canonical
URLs, and a Shopify taxonomy category per product.
Fetch the tool list from any store and search_catalog declares
catalog.filters.categories, an array of strings documented as "category
filters combined with OR logic", next to catalog.filters.price.{min,max}.
So an agent should be able to ask for 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. Then we tried it.
Method
200 stores, drawn deterministically from a corpus of 10,099 known Shopify
storefronts: sort the hostnames, take every Nth. Reproducible, so nobody has
to take "we picked 200 stores" on trust. Run on 2 September 2026. Every store
got the same five calls, 10 products requested each time:
| # | Call | Filter sent | A working filter would |
|---|---|---|---|
| 1 | Control | none | return products |
| 2 | Impossible category | gid://shopify/TaxonomyCategory/zz-99-99-99 |
return nothing |
| 3 | The store's own category | a category the control's products carry | return at least that product |
| 4 | Same, unwrapped | the bare id without gid://...
|
the other form an agent would try |
| 5 | Price control | price.max = 1 |
return nothing - nothing costs a cent |
Calls 2 and 3 only mean something together. Call 2 alone cannot distinguish
"the filter is ignored" from "the filter rejects everything". Those are
opposite findings, and both happen.
The query matters more than it looks. Generic words ("gift", "set", "new")
surface a catalogue's odd corners rather than its catalogue, and produce
numbers that are measured honestly and still wrong. Every query here is three
words 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.categories
|
Stores |
|---|---|
| Ignored it - the impossible category returned the full unfiltered set | 186 |
| Rejected everything - zero for every value, including its own products' category | 4 |
| Filtered correctly - impossible category empty, own category not | 0 |
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 missing-data story. The category is there. The filter does not
use it.
The control is the whole reason this is publishable
A null result is worth nothing unless you can show the request worked. The
control is price.max, sent in the same envelope to the same store with one
field changed.
- On 150 of 190 stores,
price.maxof one cent correctly returned nothing. - On 148 of those, the very same request that honoured price ignored the category.
One field in filters moves the result and the other does not, in the same
call, on the same store. If nothing had moved, the right conclusion would have
been that we were sending it wrong.
The bug we shipped first
The first full run 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.categoriesis 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 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. This is the exact failure this kind
of post usually ships with, and the only defence is publishing the rows.
What it does and does not mean
It does not mean an uncategorised product is invisible to agents. Nothing
is being filtered out of anything, so a blank category excludes you from
nothing. That was the sentence we expected to write, and it is false.
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 is ordinary for a young protocol doing a
hard thing. The lesson is older than agentic commerce: test what an endpoint
does rather than reading what it declares.
Reproduce it in one call
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. Calling
the endpoint requires serving a UCP platform profile the store can fetch;
ours declares empty payment_handlers, because a scanner takes no payments.
All 190 readings, one row per store - domain, query, products returned for
each of the five calls, and the verdict - are in
filter-survey.csv.
Every row names a domain you can re-run yourself, which is the point of
publishing it.
Originally published at
shelfglance.com/research/ucp-category-filter,
where the corpus of 10,099 scanned storefronts lives.
Top comments (0)