AI agents vs. reCAPTCHA Enterprise — building a pharmacy MCP that ships
The pitch was simple: tell Grok what I need, it places the order at my local pharmacy. The path to "simple" took a week. This is what I learned about reCAPTCHA Enterprise hostname enforcement, why VTEX silently drops your request when the captcha came from the wrong origin, and the architecture I shipped that gets ~95% of the value without ever beating Google.
The setup
Farmacias del Pueblo is the Argentine pharmacy chain I order from. Their site runs on VTEX, the LATAM-dominant e-commerce platform. VTEX exposes a public, well-documented JSON API for catalog and cart — that part was a gift. I wired up a Cloudflare Worker MCP with eleven tools (search, browse, cart CRUD, shipping quotes), bound it to botica.ask-meridian.uk, and started planning the auth layer.
Endpoint table for the curious — these all work without authentication and were my evidence the hard part would be auth, not data:
| Flow | Endpoint | Verified |
|---|---|---|
| Search products | GET /api/catalog_system/pub/products/search/<term> | 3 SKUs returned for "pañales" |
| Category tree | GET /api/catalog_system/pub/category/tree/3 | 14 root categories |
| Create anonymous cart | POST /api/checkout/pub/orderForm | orderFormId returned |
| Add SKU to cart | POST /api/checkout/pub/orderForm/<id>/items | cart populates with prices and installment options |
So far so good. The trouble starts when you try to make the worker authenticate as a user — i.e., have the MCP run errands "as me" instead of as an anonymous shopper.
VTEX login is gated by reCAPTCHA Enterprise on every endpoint
VTEX offers two passwordless flows on this tenant: classic (email + password) and accesskey (passwordless 6-digit code via email). I tried classic first — the password I had was generated by 1Password, formatted like nekdo3-xidkow-ciQduc. Five attempts later, all returning WrongCredentials, I started to suspect something other than a typo:
$ curl -s "https://www.farmaciasdelpueblo.com.ar/api/vtexid/pub/authentication/start"
showClassicAuthentication: True
showAccessKeyAuthentication: True
oauthProviders: ['Google']
Then I noticed the reCAPTCHA script tag in the page source:
<script src="https://www.google.com/recaptcha/enterprise.js?render=6LdV7CIpAAAAAPUrHXWlFArQ5hSiNQJk6Ja-vcYM">
And the bundle that handles the login form:
vtex.react-vtexid@4.70.0,
useSendAccessKey, useLogInWithAccessKey, useLogInWithPassword, RecaptchaProvider
The RecaptchaProvider wraps every authentication hook. So both classic password and access-key flows expect a fresh reCAPTCHA Enterprise token on each call. My WrongCredentials response was ambiguous — it could mean "wrong password" or "captcha missing/invalid"; VTEX deliberately collapses both into the same error message.
The clever-looking trick
reCAPTCHA Enterprise v3 is unsolvable server-side — it's score-based, browser-fingerprint-bound, with ~2 minute token TTLs. But I don't need to solve it server-side: I just need a fresh token, generated wherever Google is willing to issue one. The architecture I drew on the napkin:
- The worker serves a small static page at
/loginthat loadshttps://www.google.com/recaptcha/enterprise.js?render=<site-key>. - The user types their email, the page calls
grecaptcha.enterprise.execute(SITE_KEY, {action: 'login'}), gets a token. - Token + email get POSTed to the worker.
- Worker forwards to VTEX's
/api/vtexid/pub/authentication/accesskey/sendwith the token in the body. - VTEX queues the email. Code lands at the user's inbox.
- User pastes the code, page generates another captcha token, worker validates, gets a
VtexIdclientAutCookie. - Cookie is bound to the worker's IP — not the user's. The worker can use it indefinitely. Cron refreshes it via
/api/vtexid/refreshtoken/webstorebefore expiry.
The interesting part is the IP-binding asymmetry: VTEX binds the cookie to whoever made the auth request, not to "the user". When the worker is the one calling validate, the resulting cookie is the worker's. You only need the browser as a captcha-token-generator; you don't need it for any session traffic afterwards.
I shipped this. Pushed the worker, configured DNS, wired Grok's connector. Two clicks: the OAuth dance into Grok, then the access-key dance for VTEX. The page said ✓ Code sent. Check your inbox.
Nothing arrived.
The diagnostic
I had instrumented the /api/auth/send handler with structured logs. Tailing the worker while clicking the button gave me this:
[auth/send] email=lucas.kempe@icloud.com recaptcha_len=2233
[auth/send] start: token_len=64 accessKey=true
[auth/send] vtex_status=200 vtex_body="{}"
The browser did generate a token (2233 chars — real reCAPTCHA Enterprise output, not a stub). The worker did forward it. VTEX did respond 200. But the body was empty {} and no email was queued.
That's the textbook signature of server-side hostname enforcement. When VTEX's backend received the token, it called Google's assessments.create endpoint to verify it. Google's response includes the hostname of the page that generated the token — in this case, botica.ask-meridian.uk. The pharmacy's reCAPTCHA Enterprise admin has www.farmaciasdelpueblo.com.ar as its allowed domain. VTEX checked, didn't match, dropped the email queue, and returned a polite 200 {} so attackers couldn't infer which step failed.
What I'd need to actually beat this
Three options, all of them more complex than the original plan:
- A real browser running on the pharmacy's own origin. Browserbase ($10–30/mo) or a Playwright sidecar on Fly (~$5/mo + maintenance). The browser navigates to
https://www.farmaciasdelpueblo.com.ar/login, runs the access-key flow as a human would, captures theVtexIdclientAutCookie, ships it back to the worker. The cookie's IP-binding now matches the cloud browser's IP, not mine. Maintenance: when VTEX's site rotates anything, you fix it in the headless browser script. - Captcha-solving service (2captcha, anticaptcha). $2–3 per 1000 solves, ~30s latency, mediocre reliability for v3 score-based — they often return tokens with low scores that VTEX still rejects. And the hostname problem persists; the solver's IP is also wrong.
- Manual captcha relay — the user copies a fresh
grecaptcha.execute()output from the pharmacy site's console into the MCP within 2 minutes, every login. Works once. Doesn't scale.
None of these are right for a pharmacy MCP I'd use weekly-ish from my phone. So I pivoted.
The pivot: anonymous cart + URL hand-off
VTEX has a public cart hand-off URL:
https://www.farmaciasdelpueblo.com.ar/checkout/cart/add?sku=10105&qty=2&seller=1&sku=10996&qty=1&seller=1&redirect=true&sc=1
When you open this URL in your logged-in browser, VTEX appends those SKUs to your existing cart and routes you to checkout. So the architecture became:
- The worker keeps an anonymous orderForm in KV. All cart tools (
view_cart,add_to_cart,remove_from_cart, etc.) operate on it without authentication. - Search, browse, recommend, "what did I buy last month" — all chat-driven, headless.
prepare_checkoutreads the orderForm items and emits a/checkout/cart/addURL with all SKUs and quantities.- I open the URL in my browser. Items append to my real cart. I'm already logged in (my browser, my captcha, my saved cards). I tap Pagar.
What the AI loses: nothing observable from my POV. The "fully automated" version still required tapping the 3-D Secure prompt that AR banks throw on first use after a fresh session — there was always going to be a tap. The AI part of the workflow (search, recommend, build cart over multiple chat sessions) all happens upstream of payment, and that part is fully automated.
The eleven tools
| Tool | What it does |
|---|---|
search_products | Search the catalog (Spanish queries work best) |
get_categories | 14-node category tree |
browse_category | Page through products in a category |
view_cart | Items, totals, shipping options, payment systems |
add_to_cart | Add a SKU by id from search |
remove_from_cart | Remove by index |
update_cart_item | Change quantity (0 removes) |
clear_cart | Drop the stored orderForm — start fresh |
set_shipping_address | Set ZIP for delivery quote |
get_shipping_options | List delivery options + prices |
prepare_checkout | Emit the /checkout/cart/add URL for the browser hand-off |
What's still in the codebase but dormant
I left the captcha-mediated login plumbing in the repo on purpose: /login, /api/auth/send, /api/auth/validate, the JS in login.html, the cron-based session refresher, and the four VTEX auth functions in vtex.mjs. They wired up correctly. They're shaped right for VTEX's REST auth. They just can't pass the hostname check from a Cloudflare Worker.
The day I want to upgrade to Browserbase mode (because, say, I want a weekly recurring order to fire while I sleep), the consumer side is already in place. I just need to add an /api/auth/import-cookie endpoint that accepts a freshly-minted VtexIdclientAutCookie from the headless browser and writes it to KV. Everything else cascades.
Lessons
- Start with empirical recon, not architecture diagrams. I burned a day designing a flow that turned out to be impossible. Five minutes of inspecting the page bundle would have shown me
RecaptchaProviderwraps every auth hook. I would have started from "anonymous cart + hand-off" and saved the rest. - 200 OK is not "the API did what you asked." When you're proxying behind anti-abuse infrastructure, treat ambiguous successes as failures and instrument them. The empty-body 200 is intentional — it's a feature of the security model, not a bug in your code.
- "Fully automated" was never the right goal here. Argentine 3-D Secure means there was always going to be a manual confirmation tap on first use after a fresh session. The hand-off pattern keeps that tap exactly where it already worked, and gives you the AI value upstream.
- Keep the dormant code. The path I couldn't take today is the same path I might want tomorrow. Deleting working-but-blocked plumbing is a footgun.
Source at github.com/LuuOW/pharmacy-mcp. Live at botica.ask-meridian.uk. The MCP endpoint at botica.ask-meridian.uk/mcp connects to Grok / Claude / ChatGPT via OAuth 2.1 + PKCE — same shape as the Meridian connector covered in an earlier post.