AI agents vs. reCAPTCHA Enterprise — banner

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:

FlowEndpointVerified
Search productsGET /api/catalog_system/pub/products/search/<term>3 SKUs returned for "pañales"
Category treeGET /api/catalog_system/pub/category/tree/314 root categories
Create anonymous cartPOST /api/checkout/pub/orderFormorderFormId returned
Add SKU to cartPOST /api/checkout/pub/orderForm/<id>/itemscart 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:

  1. The worker serves a small static page at /login that loads https://www.google.com/recaptcha/enterprise.js?render=<site-key>.
  2. The user types their email, the page calls grecaptcha.enterprise.execute(SITE_KEY, {action: 'login'}), gets a token.
  3. Token + email get POSTed to the worker.
  4. Worker forwards to VTEX's /api/vtexid/pub/authentication/accesskey/send with the token in the body.
  5. VTEX queues the email. Code lands at the user's inbox.
  6. User pastes the code, page generates another captcha token, worker validates, gets a VtexIdclientAutCookie.
  7. 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/webstore before 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.

The trap: reCAPTCHA Enterprise tokens are issued by Google's JS regardless of origin — the script will happily generate a token from any page. Hostname enforcement happens on the consumer's side, when they verify the token. Many tutorials skip past this distinction. If you're proxying captcha-gated APIs and getting silent drops, this is almost certainly your problem.

What I'd need to actually beat this

Three options, all of them more complex than the original plan:

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:

  1. 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.
  2. Search, browse, recommend, "what did I buy last month" — all chat-driven, headless.
  3. prepare_checkout reads the orderForm items and emits a /checkout/cart/add URL with all SKUs and quantities.
  4. 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

ToolWhat it does
search_productsSearch the catalog (Spanish queries work best)
get_categories14-node category tree
browse_categoryPage through products in a category
view_cartItems, totals, shipping options, payment systems
add_to_cartAdd a SKU by id from search
remove_from_cartRemove by index
update_cart_itemChange quantity (0 removes)
clear_cartDrop the stored orderForm — start fresh
set_shipping_addressSet ZIP for delivery quote
get_shipping_optionsList delivery options + prices
prepare_checkoutEmit 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

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.