MCP voor PrestaShop: een agent koppelen zonder de sleutels af te geven

Een MCP-server in PHP die de Webservice-API van PrestaShop omhult met vier alleen-lezende tools, geschreven en getest op een winkel 9.1.4, en vervolgens gekoppeld aan Codex en gedeclareerd in Claude Code.

MCP voor PrestaShop: een agent koppelen zonder de sleutels af te geven
Kort antwoord

PrestaShop geeft sinds april 2026 een officiële MCP-module uit, proprietary en gebonden aan zijn eigen OAuth. Om zelf de controle te houden, schrijf je een MCP-server in PHP die de Webservice-API omhult met een tot GET beperkte sleutel en enkele tools die een vraag beantwoorden in plaats van de API door te geven. Reken op ongeveer 650 regels PHP en drie onafhankelijke weigeringslagen.

Een code-agent aan een PrestaShop-winkel koppelen is verleidelijk: hij zou de catalogus lezen, onvolledige fiches en voorraadtekorten in jouw plaats opsporen. Het risico komt niet van zijn onvoorzichtigheid, maar van het oppervlak dat je hem opent. Deze tutorial bouwt een MCP-server in PHP die maar vier lezingen blootstelt, en waardoor geen enkele schrijfactie kan.

Bestaat er al een MCP-server voor PrestaShop?

Ja, sinds de lente van 2026. PrestaShop geeft zijn eigen module uit, PrestaShop MCP Server, waarvan de publieke documentatie met zoveel woorden aankondigt dat de uitgever “holds all associated intellectual property rights” en een persoonlijke, niet-exclusieve en niet-overdraagbare licentie verleent. De introductiepagina die ik op 7 september 2026 opende, toont een laatste update van 30 april 2026. Twee beperkingen tellen voor een ontwikkelaar: de module is niet open source, en zijn authenticatie “works exclusively with PrestaShop OAuth”.

Als gedateerd en onafhankelijk bewijs van zijn bestaan: het package prestashop/ps-mcp-server-stubs, gepubliceerd op Packagist in versie 1.0.3 op 9 juli 2026, licentie proprietary, omschreven als “IDE stubs for ps_mcp_server MCP attributes and exceptions”. Externe modules kunnen er trouwens hun eigen tools op enten via de PHP-attributen PsMcpTool, PsMcpSchema en PsMcpToolAnnotations.

Aan de kant van de community geeft de GitHub-API een minder aanlokkelijk beeld. De vier repositories die op 7 september 2026 zijn gevonden, hebben na hun ontstaansweek geen enkele commit meer ontvangen.

Repository Taal Licentie Aangemaakt Laatste push Sterren
latinogino/prestashop-mcp Python MIT 30/06/2025 30/06/2025 8
promokit/prestashop-mcp TypeScript geen 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

Geen enkele is hier uitgevoerd, en dat is bewust: de keuze die telt speelt tussen een beheerst oppervlak en een ondergaan oppervlak, niet tussen officieel en community. Een server die je zelf schrijft, past in drie PHP-bestanden, en je weet, regel voor regel, wat hij kan.

Waarom de Webservice, en niet de database?

PrestaShop stelt al lang een REST-API bloot, de Webservice, “a CRUD API” volgens de ontwikkelaarsdocumentatie van versie 9. Het belang hier ligt in de toegangscontrole meer dan in de rijkdom van het model, want een sleutel van tweeëndertig tekens krijgt rechten per resource en per HTTP-methode. De documentatie zegt het zonder omwegen: “you might want a user to have read and write access on some resources, but only read access on others”.

De weigering staat dus niet in jouw PHP-code, waar een programmeerfout ze kan wissen. Ze wordt toegepast door de winkel zelf, nog voordat jouw server bestaat. Dat is dezelfde logica van verdediging in de diepte als in het artikel over sandboxen en permissies van code-agents.

De sleutel maak je aan in de back-office (Geavanceerde instellingen > Webservice) of via code, met de klasse WebserviceKey en zijn methode setPermissionForAccount(). Voor het laboratorium heb ik hem in SQL aangemaakt, omdat ik geen browser in de lus had. De toegekende rechten: alleen GET, op negen resources.

php
// Rechten toegekend aan de labsleutel: niets anders dan GET.
$permissions = [];
foreach (['products', 'categories', 'stock_availables', 'orders', 'order_states',
          'combinations', 'manufacturers', 'languages', 'currencies'] as $resource) {
    $permissions[$resource] = ['GET' => 1];
}

De controle gebeurt niet op erewoord. Drie verzoeken volstaan, met de sleutel als HTTP Basic-gebruikersnaam en een leeg wachtwoord:

bash
# Toegestane resource: 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}]}

# Resource niet in de rechten: 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"}]}

# Schrijven op een resource die nochtans alleen-lezend is toegestaan: 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 geeft dezelfde 405. En de prijs van product 1 was 23,90 € vóór deze tests, 23,90 € erna. Dat is de enige controle die telt.

Stap 1, een Webservice-client die alleen kan lezen

De tweede barrière zit in de code. De klasse die met de winkel praat, stelt maar één publieke methode bloot, get(): zelfs als de sleutel ooit per ongeluk schrijfrechten zou krijgen, zou de MCP-server geen enkele manier hebben om die te gebruiken. Ze dwingt ook output_format=JSON af, verderop zien we dat dat detail een prijs heeft.

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 . ':',   // sleutel als gebruikersnaam, leeg wachtwoord
            CURLOPT_TIMEOUT        => $this->timeout,
            CURLOPT_FOLLOWLOCATION => false,
        ]);
        $body   = curl_exec($ch);
        $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        curl_close($ch);

        // 404 op een gefilterde collectie = nul resultaten, geen storing.
        if ($status === 404) {
            return [];
        }
        if ($status !== 200) {
            throw new RuntimeException(self::readableError((string) $body, $status));
        }

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

De Webservice heeft zijn eigen queryparameter-syntax, die je één voor één moet controleren voordat je ze in code schrijft. Deze zijn gevalideerd op PrestaShop 9.1.4: display=[id,name,price] om velden te kiezen, filter[name]=%[colibri]% voor een “bevat”, filter[quantity]=[0,2000] voor een interval, filter[id]=[1|2|3] voor een “of”, sort=[price_DESC], en date=1, dat elk filter op date_add moet vergezellen.

Stap 2, vier tools die antwoorden in gewone taal, niet in JSON

Hier wordt het essentiële gespeeld, en veel MCP-servers missen dat. Een tool mag de API niet zomaar overstorten in de context van het model, hij moet een vraag beantwoorden. De vier tools van de server zijn: search_products, get_product, orders_summary en stock_alerts. Elke tool geeft een korte tekst terug en, parallel daaraan, een structuredContent voor code die het zou willen herlezen.

get_product illustreert het idee: in plaats van de ruwe fiche te leveren, berekent hij wat de agent anders zelf zou moeten afleiden.

php
// De ontbrekende velden die de agent meteen moet zien, zonder over de JSON te hoeven redeneren.
$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;

Op de demonstratiewinkel geeft de aanroep dit terug, een tekst die een mens even snel leest als een model:

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

De winst is meetbaar. Op dezelfde catalogus van negentien producten, hier is wat er in de context terechtkomt afhankelijk van de gekozen methode.

Wat de agent ontvangt Bytes
GET /api/products?display=full (XML, standaardformaat) 139.350
GET /api/products?display=full&output_format=JSON 41.126
Tekst teruggegeven door de tool search_products 1.137

Honderdtweeëntwintig keer minder dan de ruwe XML. Een MCP-server die zich beperkt tot het reproduceren van de eindpunten van de API, laat de client heel dat verschil betalen, bij elke aanroep. De redenering wordt uitgewerkt in de gids over het bouwen van een MCP-server in PHP, die ook de hele protocoltheorie behandelt die deze tutorial als bekend veronderstelt.

Stap 3, de HTTP-server: een token, een origin, een methode

Het transport past in één bestand en drie weigeringen. Een lokale MCP-server luistert op de lokale loopback, wat hem niet beschermt tegen een webpagina die open staat in de browser van dezelfde machine: vandaar de controle van de header Origin. Omdat de revisie 2026-07-28 van de specificatie de GET-stream heeft geschrapt, verloopt alles via POST. En het bearer-token staat los van de Webservice-sleutel: je kunt het intrekken zonder de winkel aan te raken.

php
// 1. Origin: zonder deze controle kan een webpagina die open staat in de browser
//    van de machine met de lokale server praten (DNS-rebinding).
$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. De revisie 2026-07-28 heeft de GET-stream geschrapt: alles verloopt via POST.
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
    header('Allow: POST');
    respond(405, ['jsonrpc' => '2.0', 'error' => ['code' => -32600, 'message' => 'POST uniquement.']]);
}

// 3. Bearer-token. De Webservice-sleutel verlaat de server nooit.
$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.'));
}

Een laatste subtiliteit is het vermelden waard: een toolfout wordt niet als JSON-RPC-fout teruggegeven. De specificatie onderscheidt “protocol errors” van “tool execution errors”, waarbij die laatste in het resultaat moeten terugkomen met isError: true, zodat het model zich kan corrigeren. Vragen naar product 9999 geeft dus een HTTP 200 terug met de tekst “Product 9999 niet gevonden.”.

De server start je met de ingebouwde server van PHP:

bash
php -S 127.0.0.1:8110 server.php

Onderstaande antwoorden zijn allemaal opgetekend op deze instantie.

Verzoek Antwoord
GET /mcp 405
POST /mcp zonder token 401, -32001
Origin: https://exemple-malveillant.test 403
initialize (protocolVersion 2025-06-18) 200, versie ongewijzigd teruggegeven
tools/list 200, vier tools, 1.928 bytes
notifications/initialized (zonder id) 202, lege body
MCP-Protocol-Version: 1900-01-01 400, -32022 met de lijst van ondersteunde versies

Codex koppelen zonder zijn configuratie aan te raken

Codex leest zijn bestand ~/.codex/config.toml, maar accepteert ook configuratiesleutels on the fly met -c. Dat is de juiste manier om een server uit te proberen: er wordt niets op schijf geschreven, en de test overleeft het commando niet. De hash van mijn config.toml was trouwens identiek vóór en na de vier uitvoeringen hieronder. Het token blijft in een omgevingsvariabele in plaats van op de commandoregel: de documentatie van Codex voorziet daarvoor bearer_token_env_var.

bash
export MCP_TOKEN="…"          # token van de MCP-server, nooit de Webservice-sleutel

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

Mijn eerste versie van de server werkte niet. Codex zag de server wel, somde de vier tools wel op, maar elke aanroep liep vast op:

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

De reflex is om de Codex-instelling te zoeken die het vrijgeeft. Dat is de verkeerde plek. Ik heb de twee variabelen gekruist over vier uitvoeringen, zelfde commando, zelfde model, zelfde winkel, alleen de server veranderde, met of zonder annotaties.

Annotaties van de server default_tools_approval_mode Resultaat
readOnlyHint niet opgegeven (standaard) completed
readOnlyHint "writes" completed
geen niet opgegeven (standaard) failed
geen "writes" failed

De kolom die beslist, is die van de annotaties, dus het protocol, en niet de instelling van de client. De MCP-specificatie voorziet optionele annotations die het gedrag van een tool beschrijven, door readOnlyHint te declareren, zegt de server aan de client dat geen enkele aanroep de externe toestand wijzigt, en Codex gelooft dat zonder dat je er iets voor moet instellen. Vier regels volstaan.

php
// De vier tools zijn alleen-lezend: dat verklaren we hier eens en voor altijd.
// Dit is de annotatie die clients lezen om te beslissen of ze de gebruiker
// om goedkeuring moeten vragen vóór elke aanroep.
$readOnly = [
    'readOnlyHint'    => true,
    'destructiveHint' => false,
    'idempotentHint'  => true,
    'openWorldHint'   => false,
];

Met die annotaties slaagt hetzelfde commando.

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.

Twee tool-aanroepen, 11.263 tokens. Een veiligheidsnuance dringt zich toch op, en de specificatie formuleert ze zelf: clients “MUST consider tool annotations to be untrusted unless they come from trusted servers”. Een annotatie readOnlyHint is een verklaring van de server, geen technische garantie. Die was hier waar omdat ik de server net zelf had geschreven. Ze vervangt noch de tot GET beperkte sleutel, noch de PHP-client zonder schrijfmethode. Ze komt erna, en voor een server van een derde is ze niets waard.

En in Claude Code?

De declaratie gebeurt in één commando, met het token als variabele gelaten zodat het bestand versiebeheerbaar blijft.

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

Het bestand .mcp.json dat in de root van het project wordt geschreven, bevat wel degelijk ${MCP_TOKEN}, en niet de waarde van het token.

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

Een server met projectscope is daarmee nog niet actief. claude mcp list toont hem als wachtend, en de documentatie legt uit waarom: “Claude Code prompts for approval in interactive sessions before using project-scoped servers from .mcp.json files”. Dat is het juiste gedrag, een gekloonde repository zou nooit vanzelf een server mogen koppelen.

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

De aanpak is dezelfde als voor een MCP-server bestemd voor WordPress, met dit verschil dat WordPress inmiddels een Abilities API en een officiële adapter levert, terwijl PrestaShop de Webservice als basis laat.

Twee valkuilen die de agent nooit zal zien

De root van de Webservice in JSON antwoordt 500. Op PrestaShop 9.1.4 met PHP 8.5 geeft GET /api/?output_format=JSON een 500 terug, terwijl dezelfde root in XML een 200 geeft. Het defect 34794 van de PrestaShop-repository beschrijft precies dit, “Uncaught TypeError: array_filter()” bij JSON-uitvoer, en het staat nog altijd open: gemeld op 9 december 2023, laatste activiteit op 10 augustus 2026. Laat een agent dus nooit de beschikbare resources ontdekken via de JSON-root, codeer de lijst hard.

De tijdzoneverschuiving is stil, en dat is het ergste. PrestaShop schrijft zijn datums in de tijdzone van de winkel (PS_TIMEZONE, hier Europe/Paris), terwijl de PHP die mijn server uitvoerde in UTC draaide. Mijn eerste versie van orders_summary begrensde het venster op now() - 30 dagen: ze gaf nul bestellingen terug op een winkel die er vijf telde. Geen fout, geen waarschuwing, een perfect geloofwaardige nul, die de agent zonder meer had gerapporteerd. De correctie bestaat erin te begrenzen op volledige dagen.

php
// PrestaShop schrijft zijn datums in de tijdzone van de winkel (PS_TIMEZONE), niet in
// die van het PHP-proces dat deze server uitvoert. We begrenzen dus op volledige
// dagen, anders verdwijnen enkele uren aan bestellingen zonder een woord.
$from = (new DateTimeImmutable('today -' . $days . ' days'))->format('Y-m-d 00:00:00');
$to   = (new DateTimeImmutable('tomorrow'))->format('Y-m-d 00:00:00');

Dat is de duurste foutklasse bij een agent: die welke een plausibel antwoord oplevert. Een MCP-server die aggregeert, moet worden getest op data waarvan je het aantal vooraf kent.

Waar dient het concreet voor

Drie toepassingen houden stand met deze vier alleen-lezende tools. De fiche-audit eerst: get_product geeft al de lijst met tekortkomingen terug, de agent hoeft alleen nog te doorlopen en te groeperen. Het bewaken van tekorten dan, met stock_alerts aangeroepen door een geplande agent in plaats van een mens die de back-office opent. De voorbereiding van een contentklus ten slotte: de vijftig fiches opsporen die moeten worden aangepakt, is leeswerk, ze op schaal herschrijven is iets anders, dat bij een gespecialiseerde module hoort zoals WizardAI.

Voeg geen vijfde tool toe die schrijft. De dag dat je die nodig hebt, schrijf je een tweede server, met zijn eigen sleutel, token en goedkeuringsbeleid. Twee gescheiden servers zijn beter dan één server waarvan de helft van de tools gevaarlijk is.

Wat je moet onthouden

  • PrestaShop geeft wel degelijk een officiële MCP-module uit sinds april 2026, proprietary en gebonden aan zijn eigen OAuth, de vier gevonden community-projecten zijn allemaal verlaten sinds hun ontstaansweek.
  • De beveiliging bestaat uit drie onafhankelijke lagen: Webservice-sleutel beperkt tot GET op negen resources, PHP-client zonder schrijfmethode, apart en intrekbaar MCP-token.
  • Een tool moet een vraag beantwoorden, geen API doorgeven: 1.137 bytes nuttige tekst tegenover 139.350 bytes ruwe XML voor dezelfde catalogus.
  • Zonder annotatie readOnlyHint weigert Codex een tool aan te roepen in niet-interactieve modus, ongeacht de goedkeuringsinstelling. Het is het protocol dat vrijgeeft, niet de client, maar een annotatie blijft een verklaring van de server, geen garantie.
  • Test je aggregaties op data waarvan je het aantal kent: een tijdzoneverschuiving levert een geloofwaardige nul op die niemand zal controleren.

Veelgemaakte fouten

De resources ontdekken via de JSON-root GET /api/?output_format=JSON antwoordt 500 op PrestaShop 9.1.4 met PHP 8.5 (defect open sinds december 2023), terwijl dezelfde root in XML 200 antwoordt. Codeer de lijst met resources hard in plaats van ze te laten ontdekken.
Een aggregatie begrenzen op het huidige tijdstip PrestaShop schrijft zijn datums in PS_TIMEZONE, jouw PHP draait misschien in UTC. Het venster now() - 30 dagen gaf nul bestellingen terug op een winkel die er vijf telde. Begrens op volledige dagen.
De tool-annotaties vergeten Zonder readOnlyHint weigert Codex de aanroep in niet-interactieve modus: MCP tool call requires approval, but approval policy is never. Geverifieerd op vier kruislingse uitvoeringen: geen enkele instelling van default_tools_approval_mode vervangt de annotatie.
De Webservice-sleutel meer dan GET geven De rechten worden per resource en per methode ingesteld. Een sleutel die kan schrijven, maakt alle zorg die aan de PHP-code is besteed, nutteloos.
Het token in het commando of in .mcp.json schrijven Gebruik bearer_token_env_var aan de kant van Codex en ${MCP_TOKEN} aan de kant van Claude Code, zodat het bestand versiebeheerbaar blijft.

Claude CodeCodexMCPPHPPrestaShopSécurité

Damien Flandrin Webdeveloper sinds 2010, maker van Gekkode en Email Impact. Elk artikel wordt vóór publicatie getest op een echt project. Contact
Nieuwsbrief

Nieuwe tests, tutorials en projecten, per e-mail.

Reproduceerbare tests, geversioneerde code, gedateerde resultaten. Nooit spam.