ArchitectureBoostEcom comme client UCP

BoostEcom comme client UCP

Ce que ce document est. Le contrat que nous tenons quand nous parlons aux serveurs MCP que Shopify expose sur chaque boutique : quels endpoints, quel profil d'agent, quel palier de trafic, et surtout ce que la licence…

Ce que ce document est. Le contrat que nous tenons quand nous parlons aux serveurs MCP que Shopify expose sur chaque boutique : quels endpoints, quel profil d'agent, quel palier de trafic, et surtout ce que la licence Shopify nous interdit de faire des reponses.

Ce qu'il n'est pas. La documentation de NOTRE serveur MCP, celui que les clients IA appellent sur /api/mcp/v1/[storeId]. Ca, c'est mcp-oauth.md. Les deux se ressemblent de loin et n'ont aucun rapport : ici nous sommes le client, la-bas nous sommes la ressource protegee.

Le sens de la fleche

mcp-oauth.md        client IA  ──OAuth──▶  BoostEcom  (nous = serveur)
ce document         BoostEcom  ──UCP───▶  boutique Shopify (nous = client)

Aucun jeton OAuth ne circule dans le second sens. Les serveurs Storefront MCP de Shopify sont publics et sans authentification.

Deux endpoints, pas un

Chaque boutique Shopify en sert deux, sur son propre domaine :

EndpointOutilsProfil d'agent
https://{host}/api/mcpget_cart, update_cart, search_shop_policies_and_faqsnon
https://{host}/api/ucp/mcpsearch_catalog, lookup_catalog, get_productobligatoire

Les envoyer tous au premier est le defaut le plus couteux, parce qu'il ne produit ni erreur de type ni exception : juste une reponse vide.

Le second se DEMANDE, il ne se suppose pas

/api/ucp/mcp est la valeur sur laquelle l'exemple de reference de Shopify termine. C'est donc le bon repli, et ce n'etait pas le bon chemin nominal : le protocole prevoit que le marchand declare son endpoint sur {host}/.well-known/ucp (les themes peuvent le publier via agents.md.liquid), et un marchand qui en declare un autre nous etait injoignable en silence — un 404, qui se lit comme une boutique sans catalogue et pas comme un client qui n'a jamais demande.

discoverUcp(shopDomain) lit ce document et rend { endpoint, source, document, service } :

ChampCe qu'il dit
endpointl'adresse a appeler. Toujours remplie
sourcedeclared ou fallback. Deux faits differents sur le marchand : un probe qui rapporte le premier en pensant au second enonce notre configuration comme une observation
documentle document entier, quand il y en avait un
servicel'entree dev.ucp.shopping correspondante, verbatim

service n'est pas depiece ici, et c'est deliberé : ce client ne peut pas verifier un schema pour les capacites et les versions qu'il pretendrait lire. intelligence/0136 en a besoin, il les lira sur l'entree, et il sera le seul endroit qui decide de ce que veut dire un champ absent. Une seule requete sert les deux besoins.

Aucun echec de decouverte n'est une erreur. Document absent, 404, timeout, HTML au lieu de JSON : tout resout sur le repli, comme le fait le helper de reference. Une boutique sans document de decouverte, c'est la majorite des boutiques, pas un incident.

Le document est memoise 10 minutes par hote, en memoire. Ce n'est pas un cache de catalogue : les regles d'usage interdisent de cacher les RESULTATS de recherche, qui portent les preferences vivantes du marchand sur le prix, la disponibilite et la presentation. Un descripteur de service est l'adresse, pas la reponse — et le relire avant chaque appel doublerait notre nombre de requetes chez chaque boutique lue.

L'endpoint declare est verifie avant d'etre appele

Ce document est ecrit par un tiers : c'est le seul endroit ou quelqu'un d'autre choisit une URL que nous allons ensuite appeler en POST. safeDeclaredEndpoint resout les chemins relatifs sur l'origine de la boutique, puis delegue la decision a lib/security/ssrf-guard.ts : https obligatoire, pas d'identifiants dans l'URL, refus des hotes et des CIDR internes, et resolution DNS de l'hote avant de repondre. Ce dernier point n'est pas du pedantisme protocolaire : http://169.254.169.254/… dans un fichier .well-known, c'est ainsi qu'un fetcher bien intentionne lit un service de metadonnees cloud pour le compte de quelqu'un. Un endpoint refuse retombe sur le repli et laisse une ligne de log — c'est le seul cas ou le repli masque un vrai desaccord plutot qu'un document absent.

Jusqu'en septembre 2026 cette fonction portait sa propre liste de CIDR : six expressions regulieres, 127.0.0.1, ::1, 10/8, 192.168/16, 172.16-31/12 et 169.254/16. Elle laissait donc passer tout le reste de 127/8 (dont 127.0.0.2, qui est un loopback sur n'importe quel hote Linux), 0.0.0.0 et 0/8, la plage CGNAT 100.64/10, les adresses IPv6 ULA et link-local — et surtout elle ne resolvait rien, donc un nom public dont l'enregistrement A pointe sur une IP privee traversait un garde ecrit precisement contre cette URL. C'etait le cinquieme garde SSRF ecrit a la main du depot ; il n'en reste plus qu'un (integrations/0313). Consequence assumee : safeDeclaredEndpoint et readUcpDiscovery sont desormais async, parce qu'il n'existe pas de resolution DNS synchrone et qu'un garde qui ne voit pas ou pointe reellement un nom ne vaut pas la propriete d'etre synchrone. Le cout est d'une resolution par document de decouverte, derriere le memo de dix minutes.

Le profil d'agent est une declaration, pas une identite

/api/ucp/mcp refuse toute requete qui ne porte pas params.arguments.meta["ucp-agent"].profile, une URL pointant vers un document JSON. La boutique la fetch depuis sa propre infrastructure, a chaque appel, pour negocier les capacites avant de repondre.

Le notre est servi par src/app/.well-known/ucp-agent/route.ts :

{
  "ucp": {
    "version": "2026-04-08",
    "capabilities": {
      "dev.ucp.shopping.catalog.search": [{ "version": "2026-04-08" }],
      "dev.ucp.shopping.catalog.lookup": [{ "version": "2026-04-08" }]
    }
  }
}

Trois consequences qui ne se devinent pas :

  1. Il ne contient aucun secret. C'est un document public, lu par n'importe quel marchand. Y mettre une cle serait la publier.
  2. Nous ne declarons que la lecture de catalogue. dev.ucp.shopping.cart, .checkout et .order sont volontairement absents : declarer une capacite non implementee fait reussir la negociation du marchand et echouer notre appel suivant.
  3. L'URL doit etre joignable depuis l'exterieur. localhost n'est pas un oubli sans consequence, c'est un echec garanti qui ressemble a un refus du marchand. agentProfileUrl() retombe donc sur l'origine publique canonique quand NEXT_PUBLIC_APP_URL n'est pas joignable — le document est statique et identique partout, donc pointer dessus depuis une machine de dev est correct, pas un contournement.

Les paliers de trafic

PalierCe qu'il demandeCe qu'il ouvre
Anonymousriencatalogue, aux limites de debit les plus basses
Signedsignatures HTTP RFC 9421 (ECDSA P-256)debits superieurs
Tokencredential Dev Dashboardpanier, checkout, commandes

Nous restons Anonymous et c'est un choix, pas une dette : un pipeline d'observation n'a rien a faire dans un panier. L'acces sans cle n'a en revanche aucun chemin d'augmentation de quota — a garder en tete avant de cabler quoi que ce soit qui boucle sur des milliers de boutiques.

Ce que nous n'avons PAS le droit de faire des reponses catalogue

Les regles d'usage Shopify du catalogue interdisent :

  • de mettre en cache les resultats de recherche — ils portent les preferences du marchand sur le prix, la disponibilite et la presentation, et doivent etre lus en direct ;
  • de telecharger ou re-servir les images produit — rendu temps reel uniquement, jamais copiees sur nos serveurs.

C'est la raison pour laquelle cet endpoint n'est pas une source du Spy. Tout ce que le Spy PERSISTE sur le catalogue d'un concurrent continue de venir de /products.json, que le marchand publie precisement pour ca. L'usage legitime de /api/ucp/mcp chez nous est la lecture vivante et non persistee : repondre a une question au moment ou un operateur la pose.

storefront-mcp/client.ts est donc livre sans couche de cache, et cette absence est deliberee : la reintroduire romprait la licence.

Ce que le client expose

src/features/shopify/storefront-mcp/client.ts

FonctionEndpointNote
storefrontMcpEndpoint(d) / storefrontUcpEndpoint(d)—resolveurs, normalisent l'hote
agentProfileUrl() / isFetchableOrigin(base)—l'URL du profil et sa condition de validite
ucpDiscoveryUrl(d) / discoverUcp(d)/.well-known/ucpou le marchand declare son endpoint. Ne jette jamais
readUcpDiscovery(doc, d) / safeDeclaredEndpoint(raw, origin)—le parseur et le garde-fou sur l'URL declaree (tous deux async : le garde resout le DNS)
ucpArgs(catalog, profileUrl)—l'enveloppe { meta, catalog }
unwrapResult(json)—result.structuredContent d'abord, content[0] ensuite
searchCatalog / lookupCatalog / getProduct/api/ucp/mcplecture vivante, non persistee
searchShopPoliciesAndFaqs/api/mcptexte du marchand, pas de restriction de cache
readPolicyHits(payload)—rend null, pas [], quand la forme est inconnue

Ce dernier point n'est pas un detail de style. Rendre [] sur une forme non reconnue ferait dire a une sonde « cette boutique ne publie pas de politique » alors que ce qui s'est passe, c'est que notre parseur a abandonne. La regle qui gouverne tout le pipeline Intelligence vaut ici comme ailleurs : une surface ne doit jamais enoncer comme une observation sur le marchand ce qui est l'etat de notre propre code.

Champs Inferred

Une partie des champs du Global Catalog (description, options, metadata.*, variants[].condition) est generee ou enrichie par l'IA de Shopify et marquee Inferred dans leur reference. Ce sont des signaux de decouverte, pas du texte redige par le marchand. Toute surface qui les afficherait un jour doit le dire, au meme titre qu'un champ estime chez nous n'est jamais presente comme observe.

Premier consommateur en production : les conditions de livraison

search_shop_policies_and_faqs n'est plus theorique. La sonde shipping_policy_extractor (0.3.0) l'appelle avant de tomber sur son ancien chemin, qui devinait jusqu'a quatre URL de politique (/policies/shipping-policy, /pages/shipping, …) et prenait le premier 200.

Ce que ca change n'est pas la confiance, c'est la couverture. Le parseur reste le notre — l'outil rend de la prose, pas des zones de livraison structurees — donc lire par MCP ne rend pas une regex plus fiable. En revanche une boutique qui publie ses delais dans une FAQ sous n'importe quel autre chemin etait invisible pour la liste devinee, et la sonde n'emettait alors rien du tout : pas un chiffre faux, une absence silencieuse sur une boutique qui publiait ses conditions depuis toujours.

Deux details qui portent la regle de cette page :

  • Provenance par champ. Chaque champ enregistre la porte par laquelle il est passe (…#storefront-mcp ou …#html), parce que les deux ne sont pas la meme force de preuve : l'un est le serveur du marchand qui repond sur ses propres conditions, l'autre une page trouvee en essayant quatre URL.
  • Le scrape est paresseux. Il ne tourne que pour ce que la reponse du marchand n'a pas couvert, champ par champ. Une boutique qui repond completement economise quatre requetes.

Et l'echec ne devient jamais un fait : pas d'endpoint MCP, un refus, un timeout, une forme non reconnue — tout retombe sur null, donc sur l'ancien chemin, jamais sur « cette boutique ne livre nulle part ».

Historique

Ce fichier a ete ecrit avec la correction de quatre defauts de protocole que client.ts portait depuis sa creation, aucun jamais declenche parce qu'aucun code n'importait ce fichier :

DefautRealite
catalogue envoye sur /api/mcpc'est /api/ucp/mcp
profil passe en en-tete X-UCP-Agent-Profileil voyage dans arguments.meta["ucp-agent"].profile
arguments a platils vivent sous une enveloppe catalog
reponse lue dans result.content[0]UCP repond dans result.structuredContent

Le profil d'agent, lui, etait reference comme « servi separement » : il n'etait servi nulle part.