MCP pour PrestaShop : brancher un agent sans lui laisser les clés

Un serveur MCP en PHP qui enveloppe l'API Webservice de PrestaShop avec quatre outils en lecture seule, écrit et testé sur une boutique 9.1.4, puis branché sur Codex et déclaré dans Claude Code.

MCP pour PrestaShop : brancher un agent sans lui laisser les clés
Réponse rapide

PrestaShop édite depuis avril 2026 un module MCP officiel, propriétaire et lié à son OAuth. Pour garder la main, écrivez un serveur MCP en PHP qui enveloppe l'API Webservice avec une clé limitée à GET et quelques outils qui répondent à une question plutôt que de relayer l'API. Comptez environ 650 lignes de PHP et trois couches de refus indépendantes.

Brancher un agent de code sur une boutique PrestaShop est tentant : il lirait le catalogue, repérerait les fiches incomplètes et les ruptures de stock à votre place. Le risque ne vient pas de son imprudence, mais de la surface que vous lui ouvrez. Ce tutoriel construit un serveur MCP en PHP qui n’expose que quatre lectures, et par lequel aucune écriture ne peut passer.

Existe-t-il déjà un serveur MCP pour PrestaShop ?

Oui, depuis le printemps 2026. PrestaShop édite son propre module, PrestaShop MCP Server, dont la documentation publique annonce en toutes lettres que l’éditeur « holds all associated intellectual property rights » et accorde une licence personnelle, non exclusive et non transférable. La page d’introduction que j’ai ouverte le 7 septembre 2026 affiche une dernière mise à jour au 30 avril 2026. Deux contraintes comptent pour un développeur : le module n’est pas open source, et son authentification « works exclusively with PrestaShop OAuth ».

Preuve datée et indépendante de son existence, le paquet prestashop/ps-mcp-server-stubs publié sur Packagist en version 1.0.3 le 9 juillet 2026, licence proprietary, décrit comme des « IDE stubs for ps_mcp_server MCP attributes and exceptions ». Les modules tiers peuvent d’ailleurs y greffer leurs propres outils via des attributs PHP PsMcpTool, PsMcpSchema et PsMcpToolAnnotations.

Côté communauté, l’API GitHub donne un tableau moins engageant. Les quatre dépôts trouvés le 7 septembre 2026 n’ont plus reçu un seul commit après leur semaine de création.

Dépôt Langage Licence Créé Dernier push Étoiles
latinogino/prestashop-mcp Python MIT 30/06/2025 30/06/2025 8
promokit/prestashop-mcp TypeScript aucune 12/07/2025 16/07/2025 2
florinel-chis/prestashop-mcp Python MIT 24/11/2025 24/11/2025 9
100peck/prestashop-mcp-server TypeScript MIT 01/03/2026 02/03/2026 0

Aucun n’a été exécuté ici, et c’est volontaire : le choix qui compte se joue entre surface maîtrisée et surface subie, pas entre officiel et communautaire. Un serveur que vous écrivez tient en trois fichiers PHP et vous savez, ligne à ligne, ce qu’il sait faire.

Pourquoi le Webservice, et pas la base de données ?

PrestaShop expose depuis longtemps une API REST, le Webservice, « a CRUD API » selon la documentation développeur de la version 9. Son intérêt ici tient au contrôle d’accès plus qu’à la richesse du modèle, une clé de trente-deux caractères recevant des droits par ressource et par méthode HTTP. La documentation le dit sans détour : « you might want a user to have read and write access on some resources, but only read access on others ».

Le refus n’est donc pas écrit dans votre code PHP, où une erreur de programmation peut l’effacer. Il est appliqué par la boutique, avant même que votre serveur n’existe. C’est la même logique de défense en profondeur que celle décrite dans l’article sur les sandbox et les permissions des agents de code.

La clé se crée dans le back-office (Paramètres avancés > Webservice) ou par code, avec la classe WebserviceKey et sa méthode setPermissionForAccount(). Pour le laboratoire, je l’ai créée en SQL puisque je n’avais pas de navigateur dans la boucle. Les droits accordés : GET uniquement, sur neuf ressources.

php
// Droits attribués à la clé du laboratoire : rien d'autre que GET.
$permissions = [];
foreach (['products', 'categories', 'stock_availables', 'orders', 'order_states',
          'combinations', 'manufacturers', 'languages', 'currencies'] as $resource) {
    $permissions[$resource] = ['GET' => 1];
}

La vérification ne se fait pas sur parole. Trois requêtes suffisent, la clé passant en nom d’utilisateur HTTP Basic avec un mot de passe vide :

bash
# Ressource autorisée : 200
curl -s -u "$PS_WS_KEY:" \
  "http://127.0.0.1:8097/api/products?output_format=JSON&limit=3"
{"products":[{"id":1},{"id":2},{"id":3}]}

# Ressource absente des droits : 401
curl -s -u "$PS_WS_KEY:" "http://127.0.0.1:8097/api/customers?output_format=JSON"
{"errors":[{"code":26,"message":"Resource of type \"customers\" is not allowed
 with this authentication key"}]}

# Écriture sur une ressource pourtant autorisée en lecture : 405
curl -s -X PUT -u "$PS_WS_KEY:" "http://127.0.0.1:8097/api/products/1"
<code><![CDATA[25]]></code>
<message><![CDATA[Method PUT is not allowed for the resource products
 with this authentication key]]></message>

DELETE rend le même 405. Et le prix du produit 1 valait 23,90 € avant ces essais, 23,90 € après. C’est le seul contrôle qui vaille.

Étape 1, un client Webservice qui ne sait que lire

La deuxième barrière est dans le code. La classe qui parle à la boutique n’expose qu’une méthode publique, get() : même si la clé recevait un jour des droits d’écriture par erreur, le serveur MCP n’aurait aucun moyen de s’en servir. Elle force aussi output_format=JSON, nous verrons plus bas que ce détail a un coût.

php
final class PrestaShopWebservice
{
    public function __construct(
        string $baseUrl,
        private string $key,
        private int $timeout = 10
    ) {
        $this->baseUrl = rtrim($baseUrl, '/');
    }

    public function get(string $resource, array $query = []): array
    {
        $query['output_format'] = 'JSON';
        $url = $this->baseUrl . '/api/' . ltrim($resource, '/') . '?' . http_build_query($query);

        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPGET        => true,
            CURLOPT_HTTPAUTH       => CURLAUTH_BASIC,
            CURLOPT_USERPWD        => $this->key . ':',   // clé en identifiant, mot de passe vide
            CURLOPT_TIMEOUT        => $this->timeout,
            CURLOPT_FOLLOWLOCATION => false,
        ]);
        $body   = curl_exec($ch);
        $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        curl_close($ch);

        // 404 sur une collection filtrée = zéro résultat, pas une panne.
        if ($status === 404) {
            return [];
        }
        if ($status !== 200) {
            throw new RuntimeException(self::readableError((string) $body, $status));
        }

        return json_decode((string) $body, true) ?: [];
    }
}

Le Webservice a une syntaxe de requête à lui, qu’il faut vérifier une par une avant de l’écrire dans du code. Celles-ci ont été validées sur PrestaShop 9.1.4 : display=[id,name,price] pour choisir les champs, filter[name]=%[colibri]% pour un « contient », filter[quantity]=[0,2000] pour un intervalle, filter[id]=[1|2|3] pour un « ou », sort=[price_DESC], et date=1 qui doit accompagner tout filtre sur date_add.

Étape 2, quatre outils qui répondent en français, pas en JSON

L’essentiel se joue ici, et beaucoup de serveurs MCP le manquent. Un outil ne doit pas reverser l’API dans le contexte du modèle, il doit répondre à une question. Les quatre outils du serveur sont : search_products, get_product, orders_summary et stock_alerts. Chacun renvoie un texte court et, en parallèle, un structuredContent pour le code qui voudrait le relire.

get_product illustre l’idée : plutôt que de livrer la fiche brute, il calcule ce que l’agent devrait sinon déduire lui-même.

php
// Les manques que l'agent doit voir tout de suite, sans raisonner sur le JSON.
$gaps = [];
if ($summary['meta_title'] === '') {
    $gaps[] = 'méta-titre vide';
}
if ($summary['meta_description'] === '') {
    $gaps[] = 'méta-description vide';
}
if ($longLen < 300) {
    $gaps[] = 'description longue de ' . $longLen . ' caractères';
}
if ($summary['reference'] === '') {
    $gaps[] = 'référence absente';
}
$summary['gaps'] = $gaps;

Sur la boutique de démonstration, l’appel rend ceci, un texte qu’un humain lit aussi vite qu’un modèle :

markdown
#1 T-shirt imprimé colibri (réf. demo_1)
Prix HT : 23,90 €  ·  Stock : 2400  ·  Actif : oui
Méta-titre : (vide)
Description courte : 94 caractères  ·  longue : 367 caractères
À corriger : méta-titre vide, méta-description vide

Le gain se mesure. Sur le même catalogue de dix-neuf produits, voici ce qui atterrit dans le contexte selon la méthode retenue.

Ce que reçoit l’agent Octets
GET /api/products?display=full (XML, format par défaut) 139 350
GET /api/products?display=full&output_format=JSON 41 126
Texte renvoyé par l’outil search_products 1 137

Cent vingt-deux fois moins que le XML brut. Un serveur MCP qui se contente de reproduire les points d’entrée de l’API fait payer au client la totalité de cette différence, à chaque appel. Le raisonnement est développé dans le guide sur la création d’un serveur MCP en PHP, qui porte aussi toute la théorie du protocole que ce tutoriel suppose acquise.

Étape 3, le serveur HTTP : un jeton, une origine, une méthode

Le transport tient dans un fichier et trois refus. Un serveur MCP local écoute sur la boucle locale, ce qui ne le protège pas d’une page web ouverte dans le navigateur de la même machine : d’où la vérification de l’en-tête Origin. La révision 2026-07-28 de la spécification ayant supprimé le flux GET, tout passe par POST. Et le jeton bearer est distinct de la clé Webservice : on peut le révoquer sans toucher à la boutique.

php
// 1. Origin : sans cette vérification, une page web ouverte dans le navigateur
//    de la machine peut parler au serveur local (requalification DNS).
$origin = $_SERVER['HTTP_ORIGIN'] ?? null;
if ($origin !== null && !in_array($origin, $origins, true)) {
    respond(403, ['jsonrpc' => '2.0', 'error' => ['code' => -32600, 'message' => 'Origine refusée.']]);
}

// 2. La révision 2026-07-28 a supprimé le flux GET : tout passe par POST.
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
    header('Allow: POST');
    respond(405, ['jsonrpc' => '2.0', 'error' => ['code' => -32600, 'message' => 'POST uniquement.']]);
}

// 3. Jeton bearer. La clé Webservice ne quitte jamais le serveur.
$header = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
$sent   = preg_match('/^Bearer\s+(.+)$/i', trim((string) $header), $m) === 1 ? $m[1] : '';
if ($mcpToken !== '' && !hash_equals($mcpToken, $sent)) {
    header('WWW-Authenticate: Bearer');
    respond(401, jsonrpcError($id, -32001, 'Jeton MCP absent ou invalide.'));
}

Une dernière subtilité vaut d’être notée : une erreur d’outil ne se renvoie pas en erreur JSON-RPC. La spécification distingue les « protocol errors » des « tool execution errors », ces dernières devant revenir dans le résultat avec isError: true pour que le modèle puisse se corriger. Demander le produit 9999 rend donc un 200 HTTP contenant le texte « Produit 9999 introuvable. ».

Le serveur se lance avec le serveur intégré de PHP :

bash
php -S 127.0.0.1:8110 server.php

Les réponses ci-dessous ont toutes été relevées sur cette instance.

Requête Réponse
GET /mcp 405
POST /mcp sans jeton 401, -32001
Origin: https://exemple-malveillant.test 403
initialize (protocolVersion 2025-06-18) 200, version rendue à l’identique
tools/list 200, quatre outils, 1 928 octets
notifications/initialized (sans id) 202, corps vide
MCP-Protocol-Version: 1900-01-01 400, -32022 avec la liste des versions gérées

Brancher Codex sans toucher à sa configuration

Codex lit son fichier ~/.codex/config.toml, mais accepte aussi des clés de configuration à la volée avec -c. C’est la bonne façon d’essayer un serveur : rien n’est écrit sur le disque, et l’essai ne survit pas à la commande. L’empreinte de mon config.toml était d’ailleurs identique avant et après les quatre exécutions ci-dessous. Le jeton, lui, reste dans une variable d’environnement plutôt que dans la ligne de commande : la documentation Codex prévoit bearer_token_env_var pour cela.

bash
export MCP_TOKEN="…"          # jeton du serveur MCP, jamais la clé Webservice

codex exec \
  -c 'mcp_servers.ps.url="http://127.0.0.1:8110/mcp"' \
  -c 'mcp_servers.ps.bearer_token_env_var="MCP_TOKEN"' \
  -c 'mcp_servers.ps.default_tools_approval_mode="writes"' \
  -m gpt-6-astra -s read-only --skip-git-repo-check \
  "Avec les outils ps, trouve la référence en alerte de stock sous 150 unités,
   puis dis ce qui manque dans sa fiche produit."

Ma première version du serveur ne passait pas. Codex voyait bien le serveur, listait bien les quatre outils, mais chaque appel s’arrêtait sur :

markdown
mcp: ps/stock_alerts (failed)
MCP tool call requires approval, but approval policy is never

Le réflexe est de chercher le réglage de Codex qui débloque. C’est le mauvais endroit. J’ai croisé les deux variables sur quatre exécutions, même commande, même modèle, même boutique, seul le serveur changeait, avec ou sans annotations.

Annotations du serveur default_tools_approval_mode Résultat
readOnlyHint non précisé (défaut) completed
readOnlyHint "writes" completed
aucune non précisé (défaut) failed
aucune "writes" failed

La colonne qui décide est celle des annotations, donc le protocole, et non le réglage du client. La spécification MCP prévoit des annotations facultatives décrivant le comportement d’un outil, en déclarant readOnlyHint, le serveur dit au client qu’aucun appel ne modifie l’état distant, et Codex le croit sans qu’on ait à lui régler quoi que ce soit. Quatre lignes suffisent.

php
// Les quatre outils sont en lecture seule : on le déclare une fois pour toutes.
// C'est cette annotation que les clients lisent pour décider s'il faut demander
// une approbation à l'utilisateur avant chaque appel.
$readOnly = [
    'readOnlyHint'    => true,
    'destructiveHint' => false,
    'idempotentHint'  => true,
    'openWorldHint'   => false,
];

Avec elles, la même commande aboutit.

markdown
mcp: ps/stock_alerts (completed)
mcp: ps/get_product (completed)

Référence : demo_21 — Pack Mug + Affiche encadrée (ID 15).
Stock : 100 unités, sous le seuil de 150.
Fiche incomplète : méta-titre, méta-description et description longue absents.

Deux appels d’outil, 11 263 tokens. Une précision de sécurité s’impose toutefois, et la spécification la formule elle-même : les clients « MUST consider tool annotations to be untrusted unless they come from trusted servers ». Une annotation readOnlyHint est une déclaration du serveur, pas une garantie technique. Celle-ci était vraie parce que je venais d’écrire le serveur. Elle ne remplace ni la clé limitée à GET, ni le client PHP sans méthode d’écriture. Elle vient après, et pour un serveur tiers elle ne vaut rien.

Et dans Claude Code ?

La déclaration se fait en une commande, avec le jeton laissé sous forme de variable pour que le fichier soit versionnable.

bash
claude mcp add --scope project --transport http ps http://127.0.0.1:8110/mcp \
  --header 'Authorization: Bearer ${MCP_TOKEN}'

Le fichier .mcp.json écrit à la racine du projet contient bien ${MCP_TOKEN}, et non la valeur du jeton.

json
{
  "mcpServers": {
    "ps": {
      "type": "http",
      "url": "http://127.0.0.1:8110/mcp",
      "headers": {
        "Authorization": "Bearer ${MCP_TOKEN}"
      }
    }
  }
}

Un serveur de portée projet n’est pas actif pour autant. claude mcp list le montre en attente, et la documentation explique pourquoi : « Claude Code prompts for approval in interactive sessions before using project-scoped servers from .mcp.json files ». C’est le bon comportement, un dépôt cloné ne devrait jamais brancher un serveur tout seul.

markdown
ps: http://127.0.0.1:8110/mcp (HTTP) - ⏸ Pending approval (run `claude` to approve)

La démarche est la même que pour un serveur MCP destiné à WordPress, à ceci près que WordPress fournit désormais une API d’abilities et un adaptateur officiel, là où PrestaShop laisse le Webservice comme socle.

Deux pièges que l’agent ne verra jamais

La racine du Webservice en JSON répond 500. Sur PrestaShop 9.1.4 avec PHP 8.5, GET /api/?output_format=JSON rend un 500 quand la même racine en XML rend un 200. Le défaut 34794 du dépôt PrestaShop décrit exactement cela, « Uncaught TypeError: array_filter() » en sortie JSON, et il est toujours ouvert : signalé le 9 décembre 2023, dernière activité le 10 août 2026. Ne laissez donc jamais un agent découvrir les ressources disponibles par la racine JSON, codez la liste.

Le décalage de fuseau est silencieux, et c’est le pire. PrestaShop écrit ses dates dans le fuseau de la boutique (PS_TIMEZONE, ici Europe/Paris), alors que le PHP qui exécutait mon serveur tournait en UTC. Ma première version d’orders_summary bornait la fenêtre sur now() - 30 jours : elle a rendu zéro commande sur une boutique qui en comptait cinq. Aucune erreur, aucun avertissement, un zéro parfaitement crédible, que l’agent aurait rapporté tel quel. La correction consiste à borner sur des journées entières.

php
// PrestaShop écrit ses dates dans le fuseau de la boutique (PS_TIMEZONE), pas dans
// celui du processus PHP qui exécute ce serveur. On borne donc sur des journées
// entières, sinon quelques heures de commandes disparaissent sans un mot.
$from = (new DateTimeImmutable('today -' . $days . ' days'))->format('Y-m-d 00:00:00');
$to   = (new DateTimeImmutable('tomorrow'))->format('Y-m-d 00:00:00');

C’est la classe d’erreur la plus coûteuse avec un agent : celle qui produit une réponse plausible. Un serveur MCP qui agrège doit être testé sur des données dont vous connaissez le compte à l’avance.

À quoi ça sert, concrètement

Trois usages tiennent debout avec ces quatre outils en lecture seule. L’audit de fiches d’abord : get_product renvoie déjà la liste des manques, l’agent n’a plus qu’à parcourir et regrouper. La surveillance des ruptures ensuite, avec stock_alerts appelé par un agent programmé plutôt que par un humain qui ouvre le back-office. La préparation d’un chantier de contenu enfin : repérer les cinquante fiches à reprendre est un travail de lecture, les réécrire à l’échelle en est un autre, qui relève d’un module dédié comme WizardAI.

N’ajoutez pas un cinquième outil qui écrit. Le jour où vous en aurez besoin, écrivez un second serveur, avec sa clé, son jeton et sa politique d’approbation. Deux serveurs séparés valent mieux qu’un serveur dont la moitié des outils sont dangereux.

Ce qu’il faut retenir

  • PrestaShop édite bien un module MCP officiel depuis avril 2026, propriétaire et lié à son OAuth, les quatre projets communautaires trouvés sont tous abandonnés depuis leur semaine de création.
  • La sécurité tient en trois couches indépendantes : clé Webservice limitée à GET sur neuf ressources, client PHP sans méthode d’écriture, jeton MCP distinct et révocable.
  • Un outil doit répondre à une question, pas relayer une API : 1 137 octets de texte utile contre 139 350 octets de XML brut pour le même catalogue.
  • Sans annotation readOnlyHint, Codex refuse d’appeler un outil en mode non interactif, quel que soit le réglage d’approbation. C’est le protocole qui débloque, pas le client, mais une annotation reste une déclaration du serveur, pas une garantie.
  • Testez vos agrégations sur des données dont vous connaissez le compte : un décalage de fuseau rend un zéro crédible que personne ne vérifiera.

Erreurs fréquentes

Découvrir les ressources par la racine JSON GET /api/?output_format=JSON répond 500 sur PrestaShop 9.1.4 avec PHP 8.5 (défaut ouvert depuis décembre 2023) alors que la même racine en XML répond 200. Codez la liste des ressources au lieu de la faire découvrir.
Borner une agrégation sur l'heure courante PrestaShop écrit ses dates dans PS_TIMEZONE, votre PHP tourne peut-être en UTC. La fenêtre now() - 30 jours a rendu zéro commande sur une boutique qui en comptait cinq. Bornez sur des journées entières.
Oublier les annotations d'outil Sans readOnlyHint, Codex refuse l'appel en mode non interactif : MCP tool call requires approval, but approval policy is never. Vérifié sur quatre exécutions croisées : aucun réglage de default_tools_approval_mode ne remplace l'annotation.
Donner à la clé Webservice plus que GET Les droits se règlent par ressource et par méthode. Une clé qui sait écrire rend inutile tout le soin apporté au code PHP.
Écrire le jeton dans la commande ou dans .mcp.json Utilisez bearer_token_env_var côté Codex et ${MCP_TOKEN} côté Claude Code, pour que le fichier reste versionnable.

Claude CodeCodexMCPPHPPrestaShopSécurité

Damien Flandrin Développeur web depuis 2010, créateur de Gekkode et d’Email Impact. Chaque article est testé sur un projet réel avant publication. Contact
Newsletter

Les nouveaux tests, tutoriels et projets, par e-mail.

Tests reproductibles, code versionné, résultats datés. Jamais de spam.