« Trouve-moi un pull en cachemire Zadig&Voltaire, taille S, et commande-le. »

Il y a un an, un agent IA à qui l’on demandait ça peinait à lire un HTML pensé pour des humains. Aujourd’hui, il lit un fichier texte à la racine du domaine, découvre un serveur MCP, cherche dans le même index que le site, remplit un panier invité et rend à son utilisateur un lien de paiement.

On a construit ça entre mai et août 2026. Ce n’est pas un canal qui pèse dans nos ventes, et on ne prétendra pas le contraire : l’usage reste embryonnaire. C’est un pari sur la façon dont on achètera demain, et on voulait que Zadig&Voltaire soit lisible par les agents le jour où ils deviendront un vrai point d’entrée. Voici comment c’est fait, et pourquoi la deuxième version ne ressemble plus du tout à la première.

Être lisible avant d’être achetable

Un agent ne voit pas une vitrine, il lit des documents. La première étape a donc été de lui en donner de bons.

llms.txt, mis en production fin mai, est le plus simple : un fichier Markdown à la racine du domaine, servi en statique par CloudFront. Il présente la marque en une phrase officielle, explique que les neuf storefronts vivent sur un seul domaine avec des préfixes de locale, liste les pages utiles et, surtout, dit à l’agent où aller ensuite :

## 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.

Le deuxième document est le contrat de notre API catalogue. Helios, la couche qui sert les produits au site depuis Elasticsearch, publie depuis la même semaine sa spécification OpenAPI 3 et une interface Swagger publique. Pour un agent qui veut seulement comparer des produits, c’est le chemin le plus court : du JSON, un paramètre storeCode par marché, pas de scraping.

Interface Swagger de l'API catalogue publique de Zadig&Voltaire, avec la liste des routes produits et catégories
La spécification OpenAPI de l'API catalogue, publique et navigable.

Nettoyer cette spécification a été un petit travail à part entière : un document pensé pour l’équipe parlait de notre stack interne. Une surface publique raconte ce que l’API fait, pas comment elle est construite.

Un serveur MCP pour acheter, pas seulement chercher

Lire le catalogue ne suffit pas pour commander. Pour ça, il faut un protocole qui expose des actions, et le Model Context Protocol est devenu le standard de fait côté agents.

Notre serveur expose quatorze outils, tous orientés commande invitée :

Étape Outils
Contexte set_shop_context (marché, devise, langue)
Catalogue search_products, get_product
Panier 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
Commande place_order

Pas de connexion client, pas d’historique de commandes, pas de retours : tout ce qui touche à un compte reste sur le site. Le manifeste le déclare noir sur blanc, avec la liste des données personnelles traitées (guest_email, adresses, contenu du panier) et customer_account_access: false. Un agent, ou la personne qui le configure, sait exactement à quoi il donne accès.

Voici un échange réel, raccourci, avec le serveur de production :

// → 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, en-tête 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 } }]
      }]
    } } }

Chaque outil renvoie deux choses : un résumé texte court pour le modèle, et un structuredContent typé par un schéma de sortie. Le texte aide l’agent à raisonner, la structure lui évite d’inventer un SKU.

La découverte suit la convention qui se dessine dans l’écosystème MCP : un manifeste à /.well-known/mcp.json et une « server card » à /.well-known/mcp/server-card.json. Les deux sont générés au build du site depuis la même source que les descriptions d’outils, puis servis en statique. Une description d’outil écrite deux fois finit toujours par diverger.

L’agent prépare, l’humain paie

La question qui fâche arrive vite : comment un agent paie-t-il ? Notre réponse est simple, il ne paie pas.

place_order crée la commande dans Magento avec le mode de paiement Adyen Pay-by-Link. Adyen génère une page de paiement hébergée, l’outil renvoie son URL à l’agent, qui la transmet à son utilisateur. L’humain ouvre le lien, paie par carte avec 3-D Secure ou avec un portefeuille, et le webhook Adyen met la commande à jour dans Magento, exactement comme pour n’importe quelle autre commande. Le lien expire au bout de 48 heures.

Séquence d'une commande passée par un agent : recherche, panier, adresses, puis place_order crée la commande dans Magento avec un lien de paiement Adyen que l'humain ouvre pour payer ; le webhook Adyen confirme la commande. Agent IA MCP (site) Magento Adyen Client search_products add_to_cart panier invité adresses, livraison mutations GraphQL place_order commande lien de paiement URL URL Pay-by-Link URL Pay-by-Link transmet le lien paie (carte, 3DS) webhook : payée
L'agent prépare la commande, l'humain paie : aucune donnée de carte ne transite par l'agent.

Ce choix règle trois problèmes d’un coup. Aucune donnée de carte ne transite par l’agent ni par notre serveur MCP. La validation finale reste humaine, sur une page de paiement que le client reconnaît. Et on réutilise une mécanique éprouvée : le Pay-by-Link sert déjà à nos vendeurs en boutique.

Restait un détail : on ne voulait pas proposer le Pay-by-Link sur le site, où il n’a aucun sens. Dans Magento, un plugin l’ouvre uniquement pour les requêtes qui viennent du serveur MCP, reconnues par une requête authentifiée entre serveurs :

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();
    }
}

Le validateur mémorise sa décision pour la durée de la requête et implémente ResetAfterRequestInterface. Ce n’est pas un détail : notre Magento tourne sur un serveur applicatif où les objets survivent d’une requête à l’autre. Sans remise à zéro, la décision « cette requête vient du MCP » pourrait fuiter sur la requête suivante.

Dernier garde-fou : place_order est idempotent. Un agent qui ne reçoit pas la réponse rejoue l’appel, c’est normal. Un verrou Redis par session, posé avec SET NX et libéré par un script Lua qui vérifie son propriétaire, garantit qu’un rejeu renvoie la commande déjà créée au lieu d’en créer une deuxième.

Pourquoi on l’a rapatrié dans le site

La première version, en mai, était un service autonome : une application Express sur AWS Lambda, avec son propre Redis, ses propres secrets et sa propre recherche. C’était rapide à livrer et facile à isoler. Trois mois plus tard, ce découplage était devenu le problème.

Le site passait sa recherche sur Meilisearch, et le service MCP, lui, cherchait encore ailleurs. Un agent et un humain qui tapaient la même requête n’obtenaient plus les mêmes produits. Chaque évolution de l’infrastructure du site devait être refaite à côté. Et la question de fond a été tranchée début août : le MCP n’est pas une démo, c’est un canal de vente à part entière, au même titre que le site ou l’application des boutiques.

Un canal de vente vit avec les autres canaux. Depuis le 6 août, le serveur MCP est une route du serveur Nuxt du site, sur /mcp. Il hérite de tout l’existant : l’accès direct au même index Meilisearch (parité exacte des résultats avec le site), le Redis des sessions, la limitation de débit par IP, les secrets et le déploiement. Aucun composant d’infrastructure nouveau, et le service Lambda a été supprimé.

Un agent IA découvre le serveur par des fichiers statiques, puis appelle le point d'entrée /mcp servi par le serveur Nuxt du site ; le cœur commerce s'appuie sur Meilisearch, Helios, Magento et Redis. Agent IA client MCP Découverte /llms.txt · /.well-known/mcp.json Serveur Nuxt du site Adaptateur MCP POST /mcp Cœur commerce ports · workflows Meilisearch index du site Helios fiche produit Magento panier · commande Redis sessions · verrous
Découverte par fichiers statiques, puis un seul point d'entrée MCP servi par le site lui-même.

Le code est découpé en deux couches. Un cœur commerce expose des ports (CatalogPort, CartPort, CheckoutPort) et des workflows qui ne savent rien de MCP. Un adaptateur MCP traduit le protocole en appels à ce cœur. D’autres protocoles de commerce agentique émergent, et le jour où on en branche un second, seul l’adaptateur change.

La route elle-même tient en quelques dizaines de lignes, et chacune a une raison d’être :

export default defineEventHandler(async (event) => {
  if (event.method !== 'POST') {
    // Mode JSON sans état : pas de flux SSE, donc 405 sur GET, comme l'exige la spec.
    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(() => {})
  }
})

Un serveur MCP neuf par requête, parce que le SDK refuse de connecter deux fois la même instance. Une fermeture systématique dans le finally, parce que dans un processus Node qui vit des jours, une paire serveur et transport oubliée est une fuite mémoire. Nous avons appris ce genre de leçon à nos dépens au lancement du site.

Les détails qui ne pardonnent pas

L’identifiant de session ne vient que du transport. Il est lu dans les en-têtes, jamais dans les arguments des outils, et seuls des identifiants générés par le serveur sont acceptés.

Le contexte de marché se résout, il ne se devine pas. set_shop_context fixe explicitement le store. À défaut, le serveur le déduit de la langue demandée et du pays du visiteur transmis par CloudFront. Un prix affiché en euros à un agent qui achète pour Londres serait une erreur de commande, pas un détail d’affichage.

Le CORS est ouvert, et c’est voulu. Le point d’entrée est public par conception : commande invitée, aucune authentification, aucun accès à un compte. C’est précisément parce que la surface ne donne accès à rien de personnel qu’on peut l’ouvrir.

Ce qu’on a appris

  • La lisibilité coûte peu et rapporte tout de suite. Un llms.txt soigné et une spécification OpenAPI propre sont lisibles par les assistants qui répondent à des questions sur la marque, bien avant qu’un agent ne passe commande.
  • Un canal de vente vit avec les autres. Isoler le premier prototype était le bon réflexe pour aller vite. Le garder isolé aurait été une erreur : la parité de recherche et l’infrastructure partagée valent plus que l’indépendance.
  • Garder l’humain au moment du paiement. Le Pay-by-Link rend le canal acceptable pour tout le monde : le client, l’équipe paiement et nous.
  • Écrire pour un modèle, c’est écrire des contrats. Descriptions d’outils précises, schémas de sortie, erreurs explicites : la qualité d’un serveur MCP se joue dans ces textes autant que dans le code.
  • Être honnête sur l’usage. On a construit tôt, à faible coût puisque tout réutilise le site. Le jour où les agents d’achat se généralisent, on n’aura pas à improviser.

La suite : un accès authentifié pour les clients qui veulent que leur agent commande avec leur compte.