<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: bpmcginley</title>
    <description>The latest articles on DEV Community by bpmcginley (@bpmcginley).</description>
    <link>https://dev.to/bpmcginley</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3960074%2F79ba3b05-2471-4844-840a-e39d77704778.png</url>
      <title>DEV Community: bpmcginley</title>
      <link>https://dev.to/bpmcginley</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/bpmcginley"/>
    <language>en</language>
    <item>
      <title>Shopify's agent-commerce category filter doesn't filter. We checked 190 stores.</title>
      <dc:creator>bpmcginley</dc:creator>
      <pubDate>Wed, 02 Sep 2026 15:15:18 +0000</pubDate>
      <link>https://dev.to/bpmcginley/shopifys-agent-commerce-category-filter-doesnt-filter-we-checked-190-stores-37al</link>
      <guid>https://dev.to/bpmcginley/shopifys-agent-commerce-category-filter-doesnt-filter-we-checked-190-stores-37al</guid>
      <description>&lt;p&gt;Since 2026 every Shopify storefront answers an agent-commerce endpoint at&lt;br&gt;
&lt;code&gt;POST /api/ucp/mcp&lt;/code&gt;, advertised at &lt;code&gt;GET /.well-known/ucp&lt;/code&gt;. Merchants did not&lt;br&gt;
turn it on and it is not in their admin. It speaks the Universal Commerce&lt;br&gt;
Protocol over JSON-RPC, and the tool that matters is &lt;code&gt;search_catalog&lt;/code&gt;: an AI&lt;br&gt;
shopping agent asks a store for its catalogue and gets structured product data&lt;br&gt;
back - integer prices in minor units with a currency, variants, SKUs, canonical&lt;br&gt;
URLs, and a Shopify taxonomy category per product.&lt;/p&gt;

&lt;p&gt;Fetch the tool list from any store and &lt;code&gt;search_catalog&lt;/code&gt; declares&lt;br&gt;
&lt;code&gt;catalog.filters.categories&lt;/code&gt;, an array of strings documented as "category&lt;br&gt;
filters combined with OR logic", next to &lt;code&gt;catalog.filters.price.{min,max}&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So an agent should be able to ask for running shoes and get running shoes. We&lt;br&gt;
were about to write a paragraph about what it costs a merchant to leave the&lt;br&gt;
category field blank. Then we tried it.&lt;/p&gt;
&lt;h2&gt;
  
  
  Method
&lt;/h2&gt;

&lt;p&gt;200 stores, drawn deterministically from a corpus of 10,099 known Shopify&lt;br&gt;
storefronts: sort the hostnames, take every Nth. Reproducible, so nobody has&lt;br&gt;
to take "we picked 200 stores" on trust. Run on 2 September 2026. Every store&lt;br&gt;
got the same five calls, 10 products requested each time:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Call&lt;/th&gt;
&lt;th&gt;Filter sent&lt;/th&gt;
&lt;th&gt;A working filter would&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Control&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;return products&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Impossible category&lt;/td&gt;
&lt;td&gt;&lt;code&gt;gid://shopify/TaxonomyCategory/zz-99-99-99&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;return nothing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;The store's own category&lt;/td&gt;
&lt;td&gt;a category the control's products carry&lt;/td&gt;
&lt;td&gt;return at least that product&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Same, unwrapped&lt;/td&gt;
&lt;td&gt;the bare id without &lt;code&gt;gid://...&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;the other form an agent would try&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Price control&lt;/td&gt;
&lt;td&gt;&lt;code&gt;price.max = 1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;return nothing - nothing costs a cent&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Calls 2 and 3 only mean something together. Call 2 alone cannot distinguish&lt;br&gt;
"the filter is ignored" from "the filter rejects everything". Those are&lt;br&gt;
opposite findings, and both happen.&lt;/p&gt;

&lt;p&gt;The query matters more than it looks. Generic words ("gift", "set", "new")&lt;br&gt;
surface a catalogue's odd corners rather than its catalogue, and produce&lt;br&gt;
numbers that are measured honestly and still wrong. Every query here is three&lt;br&gt;
words from one of the store's own product titles, read from its public&lt;br&gt;
&lt;code&gt;/products.json&lt;/code&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  Result
&lt;/h2&gt;

&lt;p&gt;190 stores answered.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What the store did with &lt;code&gt;filters.categories&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;Stores&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Ignored it - the impossible category returned the full unfiltered set&lt;/td&gt;
&lt;td&gt;186&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rejected everything - zero for every value, including its own products' category&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Filtered correctly - impossible category empty, own category not&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

&lt;p&gt;177 of the 190 returned at least one product carrying a taxonomy category, so&lt;br&gt;
this is not a missing-data story. The category is there. The filter does not&lt;br&gt;
use it.&lt;/p&gt;
&lt;h2&gt;
  
  
  The control is the whole reason this is publishable
&lt;/h2&gt;

&lt;p&gt;A null result is worth nothing unless you can show the request worked. The&lt;br&gt;
control is &lt;code&gt;price.max&lt;/code&gt;, sent in the same envelope to the same store with one&lt;br&gt;
field changed.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;On 150 of 190 stores, &lt;code&gt;price.max&lt;/code&gt; of one cent correctly returned nothing.&lt;/li&gt;
&lt;li&gt;On 148 of those, the very same request that honoured price ignored the category.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One field in &lt;code&gt;filters&lt;/code&gt; moves the result and the other does not, in the same&lt;br&gt;
call, on the same store. If nothing had moved, the right conclusion would have&lt;br&gt;
been that we were sending it wrong.&lt;/p&gt;
&lt;h2&gt;
  
  
  The bug we shipped first
&lt;/h2&gt;

&lt;p&gt;The first full run reported 177 stores refusing the request outright, and it&lt;br&gt;
was about to be written up as a discovery. It was our bug.&lt;/p&gt;

&lt;p&gt;A product comes back with&lt;br&gt;
&lt;code&gt;categories: [{"value": "gid://shopify/TaxonomyCategory/hb-3-2-1-1", "taxonomy": "shopify"}]&lt;/code&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;objects. &lt;code&gt;filters.categories&lt;/code&gt; 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.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Taking &lt;code&gt;.value&lt;/code&gt; fixed it, and call 4 exists because of it: if neither the&lt;br&gt;
wrapped identifier nor the bare one narrows anything, "you sent it wrong"&lt;br&gt;
stops being available as an explanation. This is the exact failure this kind&lt;br&gt;
of post usually ships with, and the only defence is publishing the rows.&lt;/p&gt;
&lt;h2&gt;
  
  
  What it does and does not mean
&lt;/h2&gt;

&lt;p&gt;It does &lt;strong&gt;not&lt;/strong&gt; mean an uncategorised product is invisible to agents. Nothing&lt;br&gt;
is being filtered out of anything, so a blank category excludes you from&lt;br&gt;
nothing. That was the sentence we expected to write, and it is false.&lt;/p&gt;

&lt;p&gt;It &lt;strong&gt;does&lt;/strong&gt; mean an agent cannot narrow a catalogue search by category today,&lt;br&gt;
whatever the schema says, on any of the 190 stores we could read.&lt;/p&gt;

&lt;p&gt;It is not a complaint about Shopify. UCP shipped in 2026; a schema arriving&lt;br&gt;
before every part of its behaviour is ordinary for a young protocol doing a&lt;br&gt;
hard thing. The lesson is older than agentic commerce: test what an endpoint&lt;br&gt;
does rather than reading what it declares.&lt;/p&gt;
&lt;h2&gt;
  
  
  Reproduce it in one call
&lt;/h2&gt;

&lt;p&gt;Pick any Shopify domain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://EXAMPLE.com/api/ucp/mcp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"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"]}}}}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it with and without the &lt;code&gt;filters&lt;/code&gt; block and compare the counts. Calling&lt;br&gt;
the endpoint requires serving a UCP platform profile the store can fetch;&lt;br&gt;
ours declares empty &lt;code&gt;payment_handlers&lt;/code&gt;, because a scanner takes no payments.&lt;/p&gt;

&lt;p&gt;All 190 readings, one row per store - domain, query, products returned for&lt;br&gt;
each of the five calls, and the verdict - are in&lt;br&gt;
&lt;a href="https://shelfglance.com/shopify-ucp/filter-survey.csv" rel="noopener noreferrer"&gt;filter-survey.csv&lt;/a&gt;.&lt;br&gt;
Every row names a domain you can re-run yourself, which is the point of&lt;br&gt;
publishing it.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at&lt;br&gt;
&lt;a href="https://shelfglance.com/research/ucp-category-filter" rel="noopener noreferrer"&gt;shelfglance.com/research/ucp-category-filter&lt;/a&gt;,&lt;br&gt;
where the corpus of 10,099 scanned storefronts lives.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>shopify</category>
      <category>api</category>
      <category>ai</category>
      <category>ecommerce</category>
    </item>
    <item>
      <title>How to Generate Link Previews Like Slack (Without the Edge-Case Hell)</title>
      <dc:creator>bpmcginley</dc:creator>
      <pubDate>Sat, 30 May 2026 14:31:38 +0000</pubDate>
      <link>https://dev.to/bpmcginley/how-to-generate-link-previews-like-slack-without-the-edge-case-hell-b0c</link>
      <guid>https://dev.to/bpmcginley/how-to-generate-link-previews-like-slack-without-the-edge-case-hell-b0c</guid>
      <description>&lt;p&gt;You've seen it a thousand times: you paste a URL into Slack, Discord, or iMessage and it blooms into a tidy card with a title, an image, and a description. It's one of the highest-trust little UI elements you can add to a chat app, a comment box, a CMS, or a bookmarking tool. A bare URL looks like spam. An unfurled link looks legit — and gets clicked.&lt;/p&gt;

&lt;p&gt;So you decide to build it. How hard can it be? You fetch the page, grab a few meta tags, done by lunch.&lt;/p&gt;

&lt;p&gt;Then reality shows up.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a link preview actually is
&lt;/h2&gt;

&lt;p&gt;The card is built from &lt;strong&gt;Open Graph&lt;/strong&gt; tags in the page's &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt;:&lt;/p&gt;

&lt;p&gt;​&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:title"&lt;/span&gt;       &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"The Verge"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:description"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"Technology, science and culture."&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image"&lt;/span&gt;       &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"https://.../cover.png"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
​```



Read those, render a card. Simple — in the demo. The pain is everything *between* "fetch the page" and "read the tags."

## The five things that break your weekend scraper

1. **Redirects.** `t.co`, `bit.ly`, and `http → https` mean the URL you were handed isn't the page you parse. You have to follow them.
2. **Missing tags.** Tons of sites have no `og:*` at all. You need a fallback chain: Open Graph → Twitter Card → `&lt;span class="nt"&gt;&amp;lt;title&amp;gt;&lt;/span&gt;`/`meta description` → first `&lt;span class="nt"&gt;&amp;lt;h1&amp;gt;&lt;/span&gt;`/`&lt;span class="nt"&gt;&amp;lt;img&amp;gt;&lt;/span&gt;`.
3. **Relative image URLs.** `og:image` is often `/img/cover.png`, not a full URL. Resolve it against the final page URL or the image just won't load.
4. **Timeouts and giant pages.** A slow or multi-megabyte page will hang your request or eat your memory. You need a hard timeout and a byte cap.
5. **SSRF — the dangerous one.** If users submit the URLs, an attacker can point you at `http://169.254.169.254` (cloud metadata) or `http://localhost` to reach internal services. You must block private/loopback/link-local IPs — on **every** redirect hop, not just the first.

That last one is why "just scrape it yourself" quietly becomes a security review. Link-preview features are a classic SSRF vector in real apps.

## The shortcut: one request, clean JSON

If you'd rather not own all of that, hand the URL to a service that already has, and get structured data back:

​

```javascript
const res = await fetch(
  "https://link-preview14.p.rapidapi.com/preview?url=" +
    encodeURIComponent(targetUrl),
  { headers: {
      "X-RapidAPI-Key": "YOUR_KEY",
      "X-RapidAPI-Host": "link-preview14.p.rapidapi.com",
  } },
);
const preview = await res.json();
​```



You get back:

​

```json
{
  "resolvedUrl": "https://www.theverge.com/",
  "title": "The Verge",
  "description": "The Verge is about technology and how it makes us feel.",
  "image": "https://www.theverge.com/static-assets/og-image.png",
  "favicon": "https://www.theverge.com/favicon.ico",
  "siteName": "The Verge",
  "themeColor": "#5200ff"
}
​```



Redirects followed, relative URLs resolved, fallbacks applied, private IPs blocked, responses cached. Every missing field comes back as `null`, so the shape never changes and your render code stays boring (the good kind).

## When to build vs. when to buy

Building it yourself is totally reasonable if you control the input — a fixed list of trusted URLs, internal pages, that kind of thing. The moment URLs are **user-submitted**, the edge cases and the SSRF surface make a purpose-built endpoint the faster, safer call.

If you want to skip the weekend, the API above is on RapidAPI with a free tier (no card): **[Link Preview API](https://rapidapi.com/bpmcginley/api/link-preview14)**. Full write-up with the field reference is [here](https://linkpreviewapi.onrender.com/blog/how-to-generate-link-previews.html).

What edge case bit you hardest building link previews? I'll add the bad ones to the article.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>api</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
