{"slug": "shopify-s-agent-commerce-category-filter-didn-t-filter-on-any-of-190-stores", "title": "Shopify's agent-commerce category filter didn't filter on any of 190 stores", "summary": "A test of 200 live Shopify storefronts found that the agent-commerce category filter declared in the Universal Commerce Protocol schema was ignored by 186 stores and rejected all values by 4, with zero stores filtering correctly, according to a reproducible audit run on 2 September 2026. The audit, which sampled 200 stores from a corpus of 10,099 known Shopify storefronts, sent a category filter to each store's /api/ucp/mcp endpoint and found that 177 of 190 responding stores returned products carrying a taxonomy category, yet the filter had no effect, while a price control filter worked on 150 of 190 stores.", "body_md": "# Shopify’s agent‑commerce category filter didn’t filter on any of 190 stores\n\nThe 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.\n\n## The endpoint\n\nSince 2026 every Shopify store answers an agent-commerce endpoint at\n`POST /api/ucp/mcp`\n\n, advertised at `GET /.well-known/ucp`\n\n, speaking\nthe [Universal Commerce Protocol](https://ucp.dev). Merchants did not switch\nit on and it is not in their admin. It exists so an AI shopping agent can ask a store for\nits catalogue directly instead of scraping the HTML, and it returns clean structured\ndata: price as an integer in minor units with a currency code, variants, SKUs, media,\ncanonical URLs, and a Shopify taxonomy category per product.\n\nIt is a JSON-RPC `tools/call`\n\nagainst a tool named\n`search_catalog`\n\n. The interesting argument is `catalog`\n\n, and inside\nit, `filters`\n\n.\n\n## What the schema says\n\nFetch the tool list from any store and `search_catalog`\n\ndeclares, among\nothers, `catalog.filters.categories`\n\n— an array of strings, documented\nas “category filters combined with OR logic” — alongside\n`catalog.filters.price.{min,max}`\n\nas integers in minor currency units.\n\nSo an agent is plainly meant to be able to say *show me your running shoes* and\nget running shoes. We were about to write a paragraph about what it costs a merchant to\nleave the category field blank, given that. Then we tried it.\n\n## Method\n\n200 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:\n\n| # | Call | Filter sent | What a working filter does |\n|---|---|---|---|\n| 1 | Control | `none` | returns products |\n| 2 | Impossible category | `gid://shopify/TaxonomyCategory/zz-99-99-99` | returns nothing — no product is in it |\n| 3 | The store’s own category | `a category the control’s own products carry` | returns something — at least the product we took it from |\n| 4 | Same, unwrapped | `the bare id without gid://…` | the other form an agent would reasonably try |\n| 5 | Price control | `price.max = 1` | returns nothing — nothing costs a cent |\n\nCalls 2 and 3 are the experiment and they are only meaningful together. An earlier\nfour-store version of this ran call 2 alone, and call 2 alone cannot tell\n*the filter is ignored* from *the filter rejects everything*. Those are\nopposite findings and, as it turns out, both happen.\n\nThe query matters more than it looks. A first attempt used generic words —\n“gift”, “set”, “new” — and produced numbers\nthat were measured honestly and were still wrong: a bad query surfaces a catalogue’s\nodd corners rather than its catalogue. Every query here is three words taken from one of\nthe store’s own product titles, read from its public\n`/products.json`\n\n.\n\n## Result\n\n190 stores answered\n\nWhat the store did with `filters.categories` | Stores |\n|---|---|\n| Ignored it — the impossible category returned the full unfiltered set | 186 |\n| Rejected everything — returned zero for every value, including the category its own products carry | 4 |\nFiltered correctly — impossible category empty,\nown category not | 0 |\n\nOf 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.\n\n177 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.\n\n## The control, which is the whole reason this is publishable\n\nA null result is worth nothing unless you can show the request worked. Ours is\n`price.max`\n\n, sent in the same envelope to the same store with one field\nchanged.\n\n- On\n**150 of 190** stores, a`price.max`\n\nof one cent correctly returned nothing. - On\n**148 stores the very same request that honoured price ignored the category**.\n\nOne field in `filters`\n\nmoves the result and the other does not, in the same\ncall, on the same store. That is the finding. If nothing had moved, the correct\nconclusion would have been that we were sending it wrong.\n\n## The mistake we made first\n\nThe first full run of this reported 177 stores *refusing* the\nrequest outright, and it was about to be written up as a discovery. It was our bug.\n\nA product comes back with\n```\ncategories: [{\"value\": \"gid://shopify/TaxonomyCategory/hb-3-2-1-1\", \"taxonomy\":\n\"shopify\"}]\n```\n\n— objects. `filters.categories`\n\nis declared as an\narray of *strings*. We were passing the object straight back in, violating the\nendpoint’s own schema, and calling its entirely correct refusal a finding about\nShopify.\n\nTaking `.value`\n\nfixed it, and call 4 above exists because of it: if neither\nthe wrapped identifier nor the bare one narrows anything, “you sent it wrong”\nstops being available as an explanation. We are describing this at length because it is\nthe exact failure this kind of post usually ships with, and the only defence against it\nis publishing the rows.\n\n## What this does and does not mean\n\n**It does not mean an uncategorised product is invisible to agents.** That was the\nsentence we expected to be able to write and it is not true — nothing is being\nfiltered out of anything, so a blank category excludes you from nothing.\n\n**It does mean an agent cannot narrow a catalogue search by category today**,\nwhatever the schema says, on any of the 190 stores we could read.\n\n**It is not a complaint about Shopify.** UCP shipped in 2026. A schema arriving\nbefore every part of its behaviour does is ordinary, and this is a young protocol doing a\nhard thing. The useful lesson is narrower and older than agentic commerce: test what an\nendpoint does rather than reading what it declares.\n\n## Reproduce it\n\nOne store, one call, no tooling. Pick any Shopify domain:\n\n```\ncurl -s https://EXAMPLE.com/api/ucp/mcp \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\n        \"name\":\"search_catalog\",\n        \"arguments\":{\"catalog\":{\"query\":\"YOUR QUERY\",\"filters\":{\n          \"categories\":[\"gid://shopify/TaxonomyCategory/zz-99-99-99\"]}}}}}'\n```\n\nRun it with and without the `filters`\n\nblock and compare the counts. Note\nthat calling the endpoint requires serving a UCP platform profile the store can fetch;\nours is at `/ucp/agent-profile.json`\n\nand declares empty\n`payment_handlers`\n\n, because a scanner takes no payments.\n\n**All 190 readings, one row per store:**\n[filter-survey.csv](/shopify-ucp/filter-survey.csv) — store, query,\nproducts returned for each of the five calls, and the verdict. Every row names a domain\nyou can re-run yourself, which is the point of publishing it.\n\n## Who ran this\n\nShelfglance, which measures what AI assistants can read from Shopify\nstorefronts and keeps a public directory of 10,099 of them.\nThis study came out of trying to write an honest sentence about what a blank category\nfield costs a merchant, discovering the obvious sentence was false, and deciding to\npublish the reason rather than the sentence. The merchant-facing version of the same\nfinding is [here](/shopify-ucp).", "url": "https://wpnews.pro/news/shopify-s-agent-commerce-category-filter-didn-t-filter-on-any-of-190-stores", "canonical_source": "https://shelfglance.com/research/ucp-category-filter", "published_at": "2026-09-02 03:39:06+00:00", "updated_at": "2026-09-02 04:22:33.038631+00:00", "lang": "en", "topics": ["ai-agents", "ai-products", "ai-infrastructure"], "entities": ["Shopify", "Universal Commerce Protocol"], "alternates": {"html": "https://wpnews.pro/news/shopify-s-agent-commerce-category-filter-didn-t-filter-on-any-of-190-stores", "markdown": "https://wpnews.pro/news/shopify-s-agent-commerce-category-filter-didn-t-filter-on-any-of-190-stores.md", "text": "https://wpnews.pro/news/shopify-s-agent-commerce-category-filter-didn-t-filter-on-any-of-190-stores.txt", "jsonld": "https://wpnews.pro/news/shopify-s-agent-commerce-category-filter-didn-t-filter-on-any-of-190-stores.jsonld"}}