“Find me a Zadig&Voltaire cashmere jumper in size S and order it.”

A year ago, an AI agent given that request struggled to read HTML built for humans. Today it reads a text file at the root of the domain, discovers an MCP server, searches the same index as the site, fills a guest cart and hands its user a payment link.

We built this between May and August 2026. It is not a channel that moves our sales, and we won’t pretend otherwise: usage is still in its infancy. It is a bet on how people will shop tomorrow, and we wanted Zadig&Voltaire to be readable by agents on the day they become a real entry point. Here is how it works, and why the second version looks nothing like the first.

Readable before purchasable

An agent doesn’t see a storefront, it reads documents. So the first step was to give it good ones.

llms.txt, live since late May, is the simplest: a Markdown file at the root of the domain, served statically by CloudFront. It introduces the brand with its official one-liner, explains that the nine storefronts share one domain with locale prefixes, lists the useful pages and, most importantly, tells the agent where to go next:

## Catalog API

A public read-only REST API exposes the full catalog in JSON, without
HTML scraping or authentication. All endpoints take a required
`storeCode` query parameter.

## AI Agents - Model Context Protocol

For transactional flows (cart, shipping, billing, guest checkout with
Adyen Pay-by-Link), use the MCP server. Account-aware actions (login,
order history, returns, loyalty) are not exposed via MCP.

The second document is the contract of our catalogue API. Helios, the layer that serves products to the site from Elasticsearch, has published its OpenAPI 3 specification and a public Swagger UI since that same week. For an agent that only wants to compare products, it is the shortest path: JSON, one storeCode parameter per market, no scraping.

Swagger UI of the public Zadig&Voltaire catalogue API, listing the product and category routes
The OpenAPI specification of the catalogue API, public and browsable.

Cleaning up that specification was a small job in itself: a document written for the team talked about our internal stack. A public surface describes what the API does, not how it is built.

Reading the catalogue is not enough to place an order. That takes a protocol that exposes actions, and the Model Context Protocol has become the de facto standard on the agent side.

Our server exposes fourteen tools, all built around guest checkout:

Step Tools
Context set_shop_context (market, currency, language)
Catalogue search_products, get_product
Cart create_guest_cart, add_to_cart, get_cart, remove_from_cart
Checkout set_guest_email, set_shipping_address, set_billing_address, list_shipping_methods, set_shipping_method, list_payment_methods
Order place_order

No customer login, no order history, no returns: anything that touches an account stays on the site. The manifest says so in plain terms, with the list of personal data processed (guest_email, addresses, cart content) and customer_account_access: false. An agent, or whoever configures it, knows exactly what it is being given access to.

Here is a real exchange with the production server, trimmed:

// → POST https://zadig-et-voltaire.com/mcp
{ "jsonrpc": "2.0", "id": 3, "method": "tools/call",
  "params": { "name": "search_products",
              "arguments": { "query": "pull cachemire", "limit": 3 } } }

// ← 200, header Mcp-Session-Id: aa5c0bb5-…
{ "result": {
    "content": [{ "type": "text", "text": "3 products found." }],
    "structuredContent": {
      "shopContext": { "shopCode": "fr_fr", "currency": "EUR", "locale": "fr-FR" },
      "products": [{
        "name": "Pull Swyny 100% Cachemire",
        "url_key": "pull-cachemire-swyny-encre-a-strass-coupe-oversize",
        "price": { "EUR": { "default": 425 } },
        "variants": [{ "sku": "KWSW03073404X03", "size": "S",
                       "stock": { "is_in_stock": true } }]
      }]
    } } }

Every tool returns two things: a short text summary for the model, and a structuredContent typed by an output schema. The text helps the agent reason; the structure keeps it from making up a SKU.

Discovery follows the convention taking shape in the MCP ecosystem: a manifest at /.well-known/mcp.json and a “server card” at /.well-known/mcp/server-card.json. Both are generated at build time from the same source as the tool descriptions, then served statically. A tool description written twice always ends up drifting.

The agent prepares, the human pays

The awkward question comes up quickly: how does an agent pay? Our answer is simple: it doesn’t.

place_order creates the order in Magento with Adyen Pay-by-Link as the payment method. Adyen generates a hosted payment page, the tool returns its URL to the agent, and the agent passes it on to its user. The human opens the link, pays by card with 3-D Secure or with a wallet, and the Adyen webhook updates the order in Magento, exactly as for any other order. The link expires after 48 hours.

Sequence of an order placed by an agent: search, cart, addresses, then place_order creates the order in Magento with an Adyen payment link that the human opens to pay; the Adyen webhook confirms the order. AI agent MCP (site) Magento Adyen Shopper search_products add_to_cart guest cart addresses, shipping GraphQL mutations place_order order payment link URL Pay-by-Link URL Pay-by-Link URL passes the link pays (card, 3DS) webhook: paid
The agent prepares the order, the human pays: no card data ever goes through the agent.

That choice solves three problems at once. No card data ever goes through the agent or through our MCP server. The final approval stays human, on a payment page the customer recognises. And we reuse a proven mechanism: our in-store sales associates already rely on Pay-by-Link.

One detail remained: we did not want to offer Pay-by-Link on the website, where it makes no sense. In Magento, a plugin enables it only for requests coming from the MCP server, recognised through an authenticated server-to-server request:

readonly class RestrictAdyenPblToMcp
{
    public function __construct(private RequestValidator $requestValidator)
    {
    }

    public function afterCanUseCheckout(Adapter $subject, bool $result): bool
    {
        if ($subject->getCode() !== AdyenPayByLinkConfigProvider::CODE) {
            return $result;
        }

        return $result || $this->requestValidator->isMcpRequest();
    }
}

The validator memoises its decision for the duration of the request and implements ResetAfterRequestInterface. That matters: our Magento runs on an application server where objects outlive a single request. Without the reset, the “this request comes from MCP” decision could leak into the next one.

Last safeguard: place_order is idempotent. An agent that misses the response will retry, and that’s expected. A per-session Redis lock, set with SET NX and released by a Lua script that checks its owner, guarantees that a retry returns the order already created instead of creating a second one.

Why we moved it into the site

The first version, in May, was a standalone service: an Express app on AWS Lambda with its own Redis, its own secrets and its own search. It was quick to ship and easy to isolate. Three months later, that isolation had become the problem.

The site was moving its search to Meilisearch, while the MCP service still searched elsewhere. An agent and a human typing the same query no longer got the same products. Every change to the site’s infrastructure had to be redone on the side. And the underlying question was settled in early August: MCP is not a demo, it is a sales channel in its own right, just like the website or the in-store app.

A sales channel lives alongside the other channels. Since 6 August, the MCP server has been a route of the site’s Nuxt server, on /mcp. It inherits everything already there: direct access to the same Meilisearch index (exact search parity with the site), the session Redis, per-IP rate limiting, secrets and deployment. No new infrastructure component, and the Lambda service was deleted.

An AI agent discovers the server through static files, then calls the /mcp endpoint served by the site's Nuxt server; the commerce core relies on Meilisearch, Helios, Magento and Redis. AI agent MCP client Discovery /llms.txt · /.well-known/mcp.json The site's Nuxt server MCP adapter POST /mcp Commerce core ports · workflows Meilisearch site index Helios product detail Magento cart · order Redis sessions · locks
Discovery through static files, then a single MCP endpoint served by the site itself.

The code is split into two layers. A commerce core exposes ports (CatalogPort, CartPort, CheckoutPort) and workflows that know nothing about MCP. An MCP adapter translates the protocol into calls to that core. Other agentic commerce protocols are emerging, and the day we plug in a second one, only the adapter changes.

The route itself is a few dozen lines, and each one is there for a reason:

export default defineEventHandler(async (event) => {
  if (event.method !== 'POST') {
    // Stateless JSON mode: no SSE stream, hence a 405 on GET, as the spec requires.
    setResponseHeader(event, 'allow', 'POST')
    setResponseStatus(event, 405)
    return { error: 'method_not_allowed', server_card: '/.well-known/mcp/server-card.json' }
  }

  const sessionId = readSessionHeader(event.node.req.headers['mcp-session-id']) || createSessionId()
  setResponseHeader(event, 'Mcp-Session-Id', sessionId)

  const mcpServer = createCommerceMcpServer(useCommerceDependencies())
  const transport = new StreamableHTTPServerTransport({ enableJsonResponse: true })
  try {
    await mcpServer.connect(transport)
    await transport.handleRequest(event.node.req, event.node.res, await readBody(event))
  }
  finally {
    await transport.close().catch(() => {})
    await mcpServer.close().catch(() => {})
  }
})

A fresh MCP server per request, because the SDK refuses to connect the same instance twice. A systematic close in the finally, because in a Node process that lives for days, a forgotten server and transport pair is a memory leak. We learned that kind of lesson the hard way when the site launched.

The details that don’t forgive

The session id comes from the transport only. It is read from the headers, never from the tool arguments, and only server-generated ids are accepted.

The market context is resolved, not guessed. set_shop_context sets the store explicitly. Failing that, the server infers it from the requested language and the visitor country passed on by CloudFront. A price shown in euros to an agent shopping for London would be an order error, not a display glitch.

CORS is open, on purpose. The endpoint is public by design: guest checkout, no authentication, no account access. It is precisely because the surface exposes nothing personal that we can open it.

What we learned

  • Readability is cheap and pays off right away. A carefully written llms.txt and a clean OpenAPI spec are readable by assistants answering questions about the brand, long before any agent places an order.
  • A sales channel lives with the others. Isolating the first prototype was the right call to move fast. Keeping it isolated would have been a mistake: search parity and shared infrastructure are worth more than independence.
  • Keep the human at the payment step. Pay-by-Link makes the channel acceptable to everyone: the customer, the payments team and us.
  • Writing for a model means writing contracts. Precise tool descriptions, output schemas, explicit errors: the quality of an MCP server lives in those texts as much as in the code.
  • Be honest about usage. We built early, at low cost since everything reuses the site. The day shopping agents go mainstream, we won’t have to improvise.

Next up: authenticated access for customers who want their agent to order with their account.