Implémenter UCP sur PrestaShop 8/9 : le guide complet
Les surfaces transactionnelles des agents IA arrivent. Google, Shopify et Walmart ont publié UCP (Universal Commerce Protocol, Apache 2.0) ; Anthropic pousse MCP. La question pour une boutique PrestaShop n'est plus si mais comment devenir lisible par ces agents. Voici comment on l'a fait — décision par décision, avec le code réel de Fondouk.
La règle d'or : implémenter le spec, pas un blog
UCP est un spec versionné (date YYYY-MM-DD) avec des JSON Schemas. On cible la release stable 2026-04-08 et on valide chaque réponse contre les schémas publiés — pas contre une copie interne. C'est ce qui garantit qu'un agent tiers nous comprend.
Le manifest de découverte vit à /.well-known/ucp. Un agent le lit, y trouve l'endpoint et les capabilities, puis interroge la boutique — rien de codé en dur :
{
"ucp": {
"version": "2026-04-08",
"services": {
"dev.ucp.shopping": [
{ "version": "2026-04-08", "transport": "rest",
"endpoint": "https://demo.fondouk.dev/fondouk/ucp",
"schema": "https://ucp.dev/2026-04-08/services/shopping/rest.openapi.json" },
{ "version": "2026-04-08", "transport": "mcp",
"endpoint": "https://demo.fondouk.dev/fondouk/ucp/mcp",
"schema": "https://ucp.dev/2026-04-08/services/shopping/mcp.json" }
]
},
"capabilities": {
"dev.ucp.shopping.catalog.search": [{ "version": "2026-04-08",
"schema": "https://ucp.dev/2026-04-08/capabilities/shopping/catalog/search.json" }],
"dev.ucp.shopping.catalog.lookup": [{ "version": "2026-04-08",
"schema": "https://ucp.dev/2026-04-08/capabilities/shopping/catalog/lookup.json" }]
},
"payment_handlers": {}
}
}
Décision n°1 — ORM/Presenter, pas le webservice
Le webservice /api de PrestaShop renvoie du brut : prix de base, pas de TTC calculé, pas de réductions appliquées, stock dans un appel séparé (N+1). Reproduire le prix vitrine imposerait de réimplémenter le moteur de prix — source de divergences.
On utilise donc les classes internes (Product::getPriceStatic, StockAvailable, SpecificPrice). Le détail le plus important est que le prix se calcule en contexte visiteur anonyme — c'est ça, le modèle de sécurité :
// La boutique affiche-t-elle le TTC ? (réglage groupe/boutique)
$displayTax = (Product::getTaxCalculationMethod() === PS_TAX_INC);
$price = Product::getPriceStatic(
$idProduct,
$displayTax, // TTC ou HT, selon le réglage d'affichage
$idProductAttribute,
$decimals,
null,
false,
/* usereduc */ true,
1,
false,
/* id_customer */ 0, // anonyme — aucun client
null,
null,
$specificPriceOutput,
true,
true,
$context,
/* use_customer_price */ false // jamais de prix spécifique client
);
// groupe = PS_UNIDENTIFIED_GROUP → les prix spécifiques PUBLICS s'appliquent,
// les prix B2B / groupe JAMAIS. Ce qu'un visiteur anonyme voit, rien de plus.
Ce seul appel, avec id_customer = 0 et use_customer_price = false, est la raison pour laquelle un prix B2B ne peut jamais fuir par une requête d'agent.
Décision n°2 — le routing /.well-known/ compatible PS8 et PS9
Le front-office n'est migré vers Symfony ni en PS8, ni en PS9 (le FrontKernel est expérimental). Le mécanisme portable est le couple ModuleFrontController + hook moduleRoutes. Seul le manifest a besoin du chemin racine ; les endpoints REST/MCP vivent sous une base module :
public function hookModuleRoutes()
{
$mod = ['fc' => 'module', 'module' => 'fondouk'];
return [
'module-fondouk-ucp' => [
'rule' => '.well-known/ucp', 'keywords' => [],
'controller' => 'ucp', 'params' => $mod,
],
'module-fondouk-catalog-search' => [
'rule' => 'fondouk/ucp/catalog/search', 'keywords' => [],
'controller' => 'catalog', 'params' => $mod + ['action' => 'search'],
],
// … lookup, product, mcp, llms.txt
];
}
Prérequis : la réécriture d'URL. Piège à connaître : un dossier .well-known/ physique (créé par Let's Encrypt) court-circuite Apache via la règle -d du .htaccess. On le détecte et on le signale ; repli fichier statique en opt-in.
Le pivot : un cœur découplé
La pièce la plus importante n'est pas le module, c'est le Capability Graph : un format pivot neutre (JSON Schema versionné), sans dépendance PrestaShop. Le module l'alimente par introspection ; des adapters le projettent en UCP (REST, MCP). Voici la forme d'un variant dans le graphe — prices est un tableau (multi-devise) et quantity est marqué interne, jamais sérialisé vers un agent :
"variant": {
"required": ["id", "prices", "availability"],
"properties": {
"id": { "type": "string" },
"prices": { "type": "array", "items": { "required": ["price"],
"properties": { "price": { "$ref": "#/$defs/money" }, "list_price": { "$ref": "#/$defs/money" } } } },
"availability": { "required": ["available"], "properties": {
"available": { "type": "boolean" }, "status": { "type": "string" },
"quantity": { "type": "integer", "description": "Interne — JAMAIS exposé par les adapters." } } }
}
}
Toute plateforme qui sait produire ce graphe hérite gratuitement de tous les adapters. C'est tout l'intérêt du pivot.
Ce qu'un agent obtient réellement
Découvrir, puis interroger — l'endpoint vient du manifest :
curl -s -X POST https://demo.fondouk.dev/fondouk/ucp/catalog/search \
-H 'Content-Type: application/json' \
-d '{"query":"t-shirt","pagination":{"limit":1}}'
{
"ucp": { "version": "2026-04-08",
"capabilities": { "dev.ucp.shopping.catalog.search": [{ "version": "2026-04-08" }] } },
"products": [{
"id": "gid://demo.fondouk.dev/1/product/1",
"title": "T-shirt imprimé colibri",
"price_range": { "min": { "amount": 2294, "currency": "EUR" },
"max": { "amount": 2294, "currency": "EUR" } },
"variants": [{
"id": "gid://demo.fondouk.dev/1/variant/1-1", "title": "S / Blanc",
"price": { "amount": 2294, "currency": "EUR" },
"availability": { "available": true, "status": "in_stock" }
}]
}],
"pagination": { "has_next_page": true, "cursor": "eyJvIjoxfQ", "total_count": 6 }
}
amount est en unités mineures (2294 = 22,94 €), devise explicite — comme l'impose le schéma UCP publié. Le même graphe alimente le serveur MCP : tools/call search_catalog renvoie les mêmes données catalogue dans son structuredContent.
Commodité GET pour les navigateurs agentiques. Le POST est le transport canonique, mais un agent de type navigateur qui ne sait émettre que des GET obtient le même résultat en passant la requête dans l'URL — non normatif, annoncé comme x-fondouk.get_convenience dans le manifest :
curl -s 'https://demo.fondouk.dev/fondouk/ucp?capability=dev.ucp.shopping.catalog.search&query=t-shirt&limit=1'
Le manifest est une promesse
Le détail qui sépare un serveur utilisable d'un serveur conforme : ne déclarer que ce qui répond réellement. Tant qu'un endpoint est un stub, sa capability n'apparaît pas — services: {}, capabilities: {}. Un manifest vide vaut mieux qu'un manifest menteur : le jour d'un test agent, un 501 sur une capability annoncée casse la confiance.
Durcissement, parce que « survivre à 50 agents » se teste
Rate limiting token-bucket (60/min, burst 120) avec Retry-After, cache invalidé par les hooks produit, plafond de taille de corps (413) et de batch (400). Prouvé sous crawl sur PS8 comme PS9 : la boutique reste réactive.
En résumé
Lecture seule, zéro configuration, public = public. La visibilité est gratuite à vie ; le reste — checkout (couche payante Fondouk Pro), autres plateformes — est du backlog. Le code est ouvert (MIT) : github.com/fondouk-dev/fondouk.
Le gratuit et le Pro cohabitent. Installez Fondouk Pro à côté du module gratuit et ce dernier se met poliment en veille — il défère ses routes pour qu'il n'y ait jamais de manifest en double — pendant que le Pro assure le service et ajoute le tableau de bord back-office. Désinstallez le Pro et le gratuit reprend le service tout seul, sans réinstallation.
L'équipe Fondouk — Synapsea