
PrestaShop has published an official MCP module since April 2026, proprietary and tied to its own OAuth. To stay in control, write an MCP server in PHP that wraps the Webservice API with a key limited to GET and a handful of tools that answer a question rather than relay the API. Expect around 650 lines of PHP and three independent layers of refusal.
Wiring a coding agent up to a PrestaShop store is tempting: it would read the catalogue, spot the incomplete listings and the stock-outs on your behalf. The risk doesn’t come from any recklessness on its part, but from the surface you open up to it. This tutorial builds an MCP server in PHP that exposes only four reads, and through which no write can ever pass.
Does an MCP server for PrestaShop already exist?
Yes, since spring 2026. PrestaShop publishes its own module, PrestaShop MCP Server, whose public documentation spells out in plain language that the publisher “holds all associated intellectual property rights” and grants a personal, non-exclusive, non-transferable licence. The introduction page I opened on 7 September 2026 shows a last update dated 30 April 2026. Two constraints matter to a developer: the module isn’t open source, and its authentication “works exclusively with PrestaShop OAuth.”
Dated, independent proof that it exists: the prestashop/ps-mcp-server-stubs package published on Packagist as version 1.0.3 on 9 July 2026, licensed proprietary, described as “IDE stubs for ps_mcp_server MCP attributes and exceptions.” Third-party modules can even graft their own tools onto it via the PHP attributes PsMcpTool, PsMcpSchema and PsMcpToolAnnotations.
On the community side, the GitHub API paints a less encouraging picture. The four repos found on 7 September 2026 haven’t received a single commit since the week they were created.
| Repo | Language | Licence | Created | Last push | Stars |
|---|---|---|---|---|---|
latinogino/prestashop-mcp | Python | MIT | 30/06/2025 | 30/06/2025 | 8 |
promokit/prestashop-mcp | TypeScript | none | 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 |
None of them were run here, and that’s deliberate: the choice that matters is between a surface you control and one you’re stuck with, not between official and community. A server you write yourself fits in three PHP files, and you know, line by line, what it can do.
Why the Webservice, and not the database?
PrestaShop has long exposed a REST API, the Webservice, “a CRUD API” according to version 9’s developer documentation. What matters here is access control more than how rich the model is, a thirty-two-character key receiving rights per resource and per HTTP method. The documentation says so plainly: “you might want a user to have read and write access on some resources, but only read access on others.”
The refusal is therefore not written into your PHP code, where a programming mistake could erase it. It’s enforced by the store itself, before your server even exists. It’s the same defence-in-depth logic described in our article on coding agent sandboxes and permissions.
The key gets created in the back office (Advanced Parameters > Webservice) or in code, with the WebserviceKey class and its setPermissionForAccount() method. For the lab, I created mine in SQL, since I had no browser in the loop. The rights granted: GET only, on nine resources.
// Rights granted to the lab key: nothing but GET.
$permissions = [];
foreach (['products', 'categories', 'stock_availables', 'orders', 'order_states',
'combinations', 'manufacturers', 'languages', 'currencies'] as $resource) {
$permissions[$resource] = ['GET' => 1];
}Verification doesn’t happen on trust. Three requests are enough, with the key passed as the HTTP Basic username and an empty password:
# Authorised 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 not in the rights: 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"}]}
# Write on a resource that's only authorised for reads: 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 returns the same 405. And product 1’s price was €23.90 before these tests, €23.90 after. That’s the only check that actually matters.
Step 1, a Webservice client that only knows how to read
The second barrier is in the code. The class that talks to the store exposes only one public method, get(): even if the key were ever mistakenly granted write rights, the MCP server would have no way of using them. It also forces output_format=JSON, we’ll see below that this detail has a cost.
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 . ':', // key as username, empty password
CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_FOLLOWLOCATION => false,
]);
$body = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
// 404 on a filtered collection = zero results, not a failure.
if ($status === 404) {
return [];
}
if ($status !== 200) {
throw new RuntimeException(self::readableError((string) $body, $status));
}
return json_decode((string) $body, true) ?: [];
}
}The Webservice has its own query syntax, and each part needs checking individually before it goes into code. These were validated on PrestaShop 9.1.4: display=[id,name,price] to pick fields, filter[name]=%[colibri]% for a “contains,” filter[quantity]=[0,2000] for a range, filter[id]=[1|2|3] for an “or,” sort=[price_DESC], and date=1, which has to accompany any filter on date_add.
Step 2, four tools that answer in plain French, not JSON
The heart of it is here, and plenty of MCP servers miss it. A tool shouldn’t pour the API into the model’s context, it should answer a question. The server’s four tools are: search_products, get_product, orders_summary and stock_alerts. Each one returns a short piece of text and, alongside it, a structuredContent for any code that wants to read it back.
get_product illustrates the idea: rather than delivering the raw record, it works out what the agent would otherwise have to infer on its own.
// The gaps the agent needs to see right away, without having to reason over the 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;On the demo store, the call returns this, text a human reads just as fast as a model does:
#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 videThe gain can be measured. On the same catalogue of nineteen products, here’s what lands in the context depending on the method used.
| What the agent receives | Bytes |
|---|---|
GET /api/products?display=full (XML, the default format) | 139,350 |
GET /api/products?display=full&output_format=JSON | 41,126 |
Text returned by the search_products tool | 1,137 |
A hundred and twenty-two times less than the raw XML. An MCP server that just reproduces the API’s endpoints makes the client pay the full price of that difference, on every single call. The reasoning behind this is spelled out in our guide to building an MCP server in PHP, which also covers all the protocol theory this tutorial assumes you already know.
Step 3, the HTTP server: one token, one origin, one method
The transport fits in one file and three refusals. A local MCP server listens on the loopback, which doesn’t protect it from a web page open in the browser on that same machine: hence checking the Origin header. Since the specification’s 2026-07-28 revision dropped the GET stream, everything goes through POST. And the bearer token is separate from the Webservice key: you can revoke it without touching the store.
// 1. Origin: without this check, a web page open in the browser
// on the machine could talk to the local server (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. The 2026-07-28 revision dropped the GET stream: everything goes through POST.
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
header('Allow: POST');
respond(405, ['jsonrpc' => '2.0', 'error' => ['code' => -32600, 'message' => 'POST uniquement.']]);
}
// 3. Bearer token. The Webservice key never leaves the server.
$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.'));
}One last subtlety worth noting: a tool error doesn’t get returned as a JSON-RPC error. The specification distinguishes “protocol errors” from “tool execution errors,” the latter having to come back in the result with isError: true so the model can correct itself. Asking for product 9999 therefore returns an HTTP 200 containing the text “Produit 9999 introuvable.”
The server launches with PHP’s built-in server:
php -S 127.0.0.1:8110 server.phpAll the responses below were recorded on this instance.
| Request | Response |
|---|---|
GET /mcp | 405 |
POST /mcp with no token | 401, -32001 |
Origin: https://exemple-malveillant.test | 403 |
initialize (protocolVersion 2025-06-18) | 200, version returned unchanged |
tools/list | 200, four tools, 1,928 bytes |
notifications/initialized (with no id) | 202, empty body |
MCP-Protocol-Version: 1900-01-01 | 400, -32022 with the list of supported versions |
Connecting Codex without touching its configuration
Codex reads its ~/.codex/config.toml file, but also accepts configuration keys on the fly with -c. It’s the right way to try out a server: nothing gets written to disk, and the trial doesn’t outlive the command. My config.toml‘s checksum was, in fact, identical before and after the four runs below. The token, meanwhile, stays in an environment variable rather than on the command line: Codex’s documentation provides bearer_token_env_var for exactly that.
export MCP_TOKEN="…" # the MCP server's token, never the Webservice key
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."My first version of the server didn’t work. Codex could see the server just fine, listed all four tools just fine, but every call stopped dead on:
mcp: ps/stock_alerts (failed)
MCP tool call requires approval, but approval policy is neverThe instinct is to go looking for the Codex setting that unblocks it. That’s the wrong place to look. I crossed the two variables over four runs, same command, same model, same store, only the server changed, with or without annotations.
| Server annotations | default_tools_approval_mode | Result |
|---|---|---|
readOnlyHint | unset (default) | completed |
readOnlyHint | "writes" | completed |
| none | unset (default) | failed |
| none | "writes" | failed |
The column that decides is the annotations one, so the protocol, and not the client’s setting. The MCP specification provides optional annotations describing a tool’s behaviour, by declaring readOnlyHint, the server tells the client that no call ever modifies remote state, and Codex believes it without needing any setting adjusted at all. Four lines are enough.
// All four tools are read-only: we declare it once and for all.
// This is the annotation clients read to decide whether to ask
// the user for approval before every call.
$readOnly = [
'readOnlyHint' => true,
'destructiveHint' => false,
'idempotentHint' => true,
'openWorldHint' => false,
];With them in place, the same command succeeds.
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.Two tool calls, 11,263 tokens. One security caveat is worth stressing, though, and the specification states it itself: clients “MUST consider tool annotations to be untrusted unless they come from trusted servers.” A readOnlyHint annotation is a claim the server makes about itself, not a technical guarantee. This one happened to be true because I had just written the server. It replaces neither the key limited to GET, nor the PHP client with no write method. It comes after those, and for a third-party server it’s worth nothing.
And in Claude Code?
Declaring it takes one command, with the token left as a variable so the file stays versionable.
claude mcp add --scope project --transport http ps http://127.0.0.1:8110/mcp \
--header 'Authorization: Bearer ${MCP_TOKEN}'The .mcp.json file written at the root of the project does contain ${MCP_TOKEN}, not the token’s actual value.
{
"mcpServers": {
"ps": {
"type": "http",
"url": "http://127.0.0.1:8110/mcp",
"headers": {
"Authorization": "Bearer ${MCP_TOKEN}"
}
}
}
}A project-scoped server isn’t active just for that, though. claude mcp list shows it as pending, and the documentation explains why: “Claude Code prompts for approval in interactive sessions before using project-scoped servers from .mcp.json files.” That’s the right behaviour, a cloned repo should never wire up a server all by itself.
ps: http://127.0.0.1:8110/mcp (HTTP) - ⏸ Pending approval (run `claude` to approve)The approach is the same as for an MCP server aimed at WordPress, except that WordPress now provides an abilities API and an official adapter, whereas PrestaShop leaves the Webservice as the foundation.
Two traps the agent will never see
The Webservice root in JSON answers 500. On PrestaShop 9.1.4 with PHP 8.5, GET /api/?output_format=JSON returns a 500 where the same root in XML returns a 200. PrestaShop repo issue 34794 describes exactly this, “Uncaught TypeError: array_filter()” on JSON output, and it’s still open: reported on 9 December 2023, last activity on 10 August 2026. So never let an agent discover the available resources through the JSON root, hardcode the list instead.
The timezone mismatch is silent, and that’s what makes it the worst one. PrestaShop writes its dates in the store’s timezone (PS_TIMEZONE, here Europe/Paris), while the PHP running my server was on UTC. My first version of orders_summary bounded the window at now() - 30 days: it returned zero orders on a store that had five. No error, no warning, a perfectly believable zero, which the agent would have reported exactly as-is. The fix is to bound it on whole days.
// PrestaShop writes its dates in the shop's timezone (PS_TIMEZONE), not in
// the timezone of the PHP process running this server. So we bound it on whole
// days, otherwise a few hours of orders vanish without a word.
$from = (new DateTimeImmutable('today -' . $days . ' days'))->format('Y-m-d 00:00:00');
$to = (new DateTimeImmutable('tomorrow'))->format('Y-m-d 00:00:00');This is the most costly class of error with an agent: the one that produces a plausible-looking answer. Any MCP server that aggregates data has to be tested against data whose count you already know in advance.
What is this actually good for
Three uses hold up with these four read-only tools. Listing audits first: get_product already returns the list of gaps, all the agent has to do is walk through them and group them. Stock-out monitoring next, with stock_alerts called by a scheduled agent rather than a human opening the back office. Scoping a content project, finally: spotting the fifty listings that need work is a reading job, rewriting them at scale is a different one, one for a dedicated module like WizardAI.
Don’t bolt on a fifth tool that writes. The day you actually need one, write a second server, with its own key, its own token, and its own approval policy. Two separate servers beat one server where half the tools are dangerous.
What to remember
- PrestaShop does publish an official MCP module since April 2026, proprietary and tied to its own OAuth, the four community projects found are all abandoned since the week they were created.
- Security holds on three independent layers: a Webservice key limited to
GETon nine resources, a PHP client with no write method, a separate, revocable MCP token. - A tool should answer a question, not relay an API: 1,137 bytes of useful text against 139,350 bytes of raw XML for the same catalogue.
- With no
readOnlyHintannotation, Codex refuses to call a tool in non-interactive mode, regardless of the approval setting. It’s the protocol that unblocks it, not the client, but an annotation is still a claim the server makes about itself, not a guarantee. - Test your aggregations against data whose count you already know: a timezone mismatch produces a believable zero that nobody will ever double-check.
Common errors
GET /api/?output_format=JSON answers 500 on PrestaShop 9.1.4 with PHP 8.5 (a bug open since December 2023) while the same root in XML answers 200. Hardcode the list of resources instead of having it discovered.PS_TIMEZONE, your PHP might be running in UTC. The now() - 30 days window returned zero orders on a store that had five. Bound it on whole days instead.readOnlyHint, Codex refuses the call in non-interactive mode: MCP tool call requires approval, but approval policy is never. Verified over four crossed runs: no default_tools_approval_mode setting replaces the annotation.bearer_token_env_var on the Codex side and ${MCP_TOKEN} on the Claude Code side, so the file stays versionable.

