MCP para PrestaShop: sin darle las llaves de la tienda a un agente

Un servidor MCP en PHP que envuelve la API Webservice de PrestaShop con cuatro herramientas de solo lectura, escrito y probado sobre una tienda 9.1.4, y luego conectado a Codex y declarado en Claude Code.

MCP para PrestaShop: sin darle las llaves de la tienda a un agente
Respuesta rápida

PrestaShop edita desde abril de 2026 un módulo MCP oficial, propietario y ligado a su OAuth. Para conservar el control, escribe un servidor MCP en PHP que envuelva la API Webservice con una clave limitada a GET y unas pocas herramientas que respondan a una pregunta en vez de retransmitir la API. Cuenta unas 650 líneas de PHP y tres capas de rechazo independientes.

Conectar un agente de código a una tienda PrestaShop es tentador: leería el catálogo, detectaría las fichas incompletas y las roturas de stock en tu lugar. El riesgo no viene de su imprudencia, sino de la superficie que le abres. Este tutorial construye un servidor MCP en PHP que solo expone cuatro lecturas, y por el que ninguna escritura puede pasar.

¿Existe ya un servidor MCP para PrestaShop?

Sí, desde la primavera de 2026. PrestaShop edita su propio módulo, PrestaShop MCP Server, cuya documentación pública anuncia con todas las letras que el editor «holds all associated intellectual property rights» y concede una licencia personal, no exclusiva e intransferible. La página de introducción que abrí el 7 de septiembre de 2026 muestra una última actualización del 30 de abril de 2026. Dos restricciones importan para un desarrollador: el módulo no es de código abierto, y su autenticación «works exclusively with PrestaShop OAuth».

Prueba fechada e independiente de su existencia, el paquete prestashop/ps-mcp-server-stubs publicado en Packagist en la versión 1.0.3 el 9 de julio de 2026, licencia proprietary, descrito como unos «IDE stubs for ps_mcp_server MCP attributes and exceptions». Los módulos de terceros pueden además injertar ahí sus propias herramientas mediante atributos PHP PsMcpTool, PsMcpSchema y PsMcpToolAnnotations.

Del lado de la comunidad, la API de GitHub da un cuadro menos alentador. Los cuatro repositorios encontrados el 7 de septiembre de 2026 no han recibido ni un solo commit más después de su semana de creación.

Repositorio Lenguaje Licencia Creado Último push Estrellas
latinogino/prestashop-mcp Python MIT 30/06/2025 30/06/2025 8
promokit/prestashop-mcp TypeScript ninguna 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

Ninguno se ha ejecutado aquí, y es deliberado: la elección que importa se juega entre superficie controlada y superficie sufrida, no entre oficial y comunitario. Un servidor que tú mismo escribes cabe en tres archivos PHP y sabes, línea a línea, lo que sabe hacer.

¿Por qué el Webservice, y no la base de datos?

PrestaShop expone desde hace tiempo una API REST, el Webservice, «a CRUD API» según la documentación para desarrolladores de la versión 9. Su interés aquí está en el control de acceso más que en la riqueza del modelo, ya que una clave de treinta y dos caracteres recibe derechos por recurso y por método HTTP. La documentación lo dice sin rodeos: «you might want a user to have read and write access on some resources, but only read access on others».

El rechazo no está escrito, por tanto, en tu código PHP, donde un error de programación puede borrarlo. Lo aplica la tienda, antes incluso de que tu servidor exista. Es la misma lógica de defensa en profundidad que la descrita en el artículo sobre el sandbox y los permisos de los agentes de código.

La clave se crea en el back-office (Parámetros avanzados > Webservice) o por código, con la clase WebserviceKey y su método setPermissionForAccount(). Para el laboratorio, la creé en SQL, ya que no tenía un navegador en el circuito. Los derechos concedidos: GET únicamente, sobre nueve recursos.

php
// Permisos asignados a la clave del laboratorio: nada más que GET.
$permissions = [];
foreach (['products', 'categories', 'stock_availables', 'orders', 'order_states',
          'combinations', 'manufacturers', 'languages', 'currencies'] as $resource) {
    $permissions[$resource] = ['GET' => 1];
}

La comprobación no se hace de palabra. Bastan tres peticiones, con la clave como nombre de usuario HTTP Basic y una contraseña vacía:

bash
# Recurso autorizado: 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}]}

# Recurso ausente de los permisos: 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"}]}

# Escritura en un recurso autorizado solo en lectura: 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 devuelve el mismo 405. Y el precio del producto 1 valía 23,90 € antes de estas pruebas, 23,90 € después. Es el único control que vale.

Paso 1, un cliente Webservice que solo sabe leer

La segunda barrera está en el código. La clase que habla con la tienda solo expone un método público, get(): aunque la clave recibiera algún día derechos de escritura por error, el servidor MCP no tendría ninguna forma de usarlos. También fuerza output_format=JSON, veremos más abajo que este detalle tiene un coste.

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 . ':',   // clave como usuario, contraseña vacía
            CURLOPT_TIMEOUT        => $this->timeout,
            CURLOPT_FOLLOWLOCATION => false,
        ]);
        $body   = curl_exec($ch);
        $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        curl_close($ch);

        // 404 en una colección filtrada = cero resultados, no un fallo.
        if ($status === 404) {
            return [];
        }
        if ($status !== 200) {
            throw new RuntimeException(self::readableError((string) $body, $status));
        }

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

El Webservice tiene una sintaxis de consulta propia, que hay que comprobar una por una antes de escribirla en código. Estas se validaron sobre PrestaShop 9.1.4: display=[id,name,price] para elegir los campos, filter[name]=%[colibri]% para un «contiene», filter[quantity]=[0,2000] para un intervalo, filter[id]=[1|2|3] para un «o», sort=[price_DESC], y date=1, que debe acompañar a todo filtro sobre date_add.

Paso 2, cuatro herramientas que responden en español, no en JSON

Aquí se juega lo esencial, y muchos servidores MCP se lo pierden. Una herramienta no debe volcar la API en el contexto del modelo, debe responder a una pregunta. Las cuatro herramientas del servidor son: search_products, get_product, orders_summary y stock_alerts. Cada una devuelve un texto corto y, en paralelo, un structuredContent para el código que quiera releerlo.

get_product ilustra la idea: en vez de entregar la ficha en bruto, calcula lo que el agente tendría que deducir por sí mismo.

php
// Los datos que faltan y que el agente debe ver de inmediato, sin razonar sobre el 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;

En la tienda de demostración, la llamada devuelve esto, un texto que un humano lee tan rápido como un modelo:

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

La ganancia se mide. Sobre el mismo catálogo de diecinueve productos, esto es lo que llega al contexto según el método elegido.

Lo que recibe el agente Bytes
GET /api/products?display=full (XML, formato por defecto) 139.350
GET /api/products?display=full&output_format=JSON 41.126
Texto devuelto por la herramienta search_products 1.137

Ciento veintidós veces menos que el XML en bruto. Un servidor MCP que se limita a reproducir los puntos de entrada de la API hace pagar al cliente la totalidad de esa diferencia, en cada llamada. El razonamiento se desarrolla en la guía sobre la creación de un servidor MCP en PHP, que también aporta toda la teoría del protocolo que este tutorial da por sabida.

Paso 3, el servidor HTTP: un token, un origen, un método

El transporte cabe en un archivo y tres rechazos. Un servidor MCP local escucha en el bucle local, lo que no lo protege de una página web abierta en el navegador de la misma máquina: de ahí la comprobación de la cabecera Origin. Como la revisión 2026-07-28 de la especificación eliminó el flujo GET, todo pasa por POST. Y el token bearer es distinto de la clave Webservice: se puede revocar sin tocar la tienda.

php
// 1. Origin: sin esta comprobación, una página web abierta en el navegador
//    de la máquina puede hablar con el servidor local (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. La revisión 2026-07-28 eliminó el flujo GET: todo pasa por POST.
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
    header('Allow: POST');
    respond(405, ['jsonrpc' => '2.0', 'error' => ['code' => -32600, 'message' => 'POST uniquement.']]);
}

// 3. Token bearer. La clave Webservice nunca sale del servidor.
$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.'));
}

Un último matiz merece anotarse: un error de herramienta no se devuelve como error JSON-RPC. La especificación distingue los «protocol errors» de los «tool execution errors», estos últimos deben volver en el resultado con isError: true para que el modelo pueda corregirse. Pedir el producto 9999 devuelve entonces un 200 HTTP que contiene el texto «Producto 9999 no encontrado.».

El servidor se lanza con el servidor integrado de PHP:

bash
php -S 127.0.0.1:8110 server.php

Las respuestas de abajo se tomaron todas sobre esta instancia.

Petición Respuesta
GET /mcp 405
POST /mcp sin token 401, -32001
Origin: https://exemple-malveillant.test 403
initialize (protocolVersion 2025-06-18) 200, versión devuelta sin cambios
tools/list 200, cuatro herramientas, 1.928 bytes
notifications/initialized (sin id) 202, cuerpo vacío
MCP-Protocol-Version: 1900-01-01 400, -32022 con la lista de versiones admitidas

Conectar Codex sin tocar su configuración

Codex lee su archivo ~/.codex/config.toml, pero también acepta claves de configuración al vuelo con -c. Es la forma correcta de probar un servidor: no se escribe nada en disco, y la prueba no sobrevive al comando. La huella de mi config.toml era, por cierto, idéntica antes y después de las cuatro ejecuciones de abajo. El token, por su parte, se queda en una variable de entorno en vez de en la línea de comandos: la documentación de Codex prevé bearer_token_env_var para eso.

bash
export MCP_TOKEN="…"          # token del servidor MCP, nunca la clave 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."

Mi primera versión del servidor no pasaba. Codex veía bien el servidor, listaba bien las cuatro herramientas, pero cada llamada se detenía en:

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

El reflejo es buscar el ajuste de Codex que desbloquea. Es el sitio equivocado. Crucé las dos variables en cuatro ejecuciones, mismo comando, mismo modelo, misma tienda, solo cambiaba el servidor, con o sin anotaciones.

Anotaciones del servidor default_tools_approval_mode Resultado
readOnlyHint sin especificar (por defecto) completed
readOnlyHint "writes" completed
ninguna sin especificar (por defecto) failed
ninguna "writes" failed

La columna que decide es la de las anotaciones, es decir, el protocolo, y no el ajuste del cliente. La especificación MCP prevé anotaciones opcionales que describen el comportamiento de una herramienta, al declarar readOnlyHint, el servidor le dice al cliente que ninguna llamada modifica el estado remoto, y Codex le cree sin que haya que ajustarle nada. Bastan cuatro líneas.

php
// Las cuatro herramientas son de solo lectura: se declara de una vez por todas.
// Es esta anotación la que leen los clientes para decidir si hay que pedir
// una aprobación al usuario antes de cada llamada.
$readOnly = [
    'readOnlyHint'    => true,
    'destructiveHint' => false,
    'idempotentHint'  => true,
    'openWorldHint'   => false,
];

Con ellas, el mismo comando llega a buen puerto.

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.

Dos llamadas de herramienta, 11.263 tokens. Una precisión de seguridad se impone, sin embargo, y la especificación la formula ella misma: los clientes «MUST consider tool annotations to be untrusted unless they come from trusted servers». Una anotación readOnlyHint es una declaración del servidor, no una garantía técnica. Esta era cierta porque yo acababa de escribir el servidor. No sustituye ni a la clave limitada a GET, ni al cliente PHP sin método de escritura. Viene después, y para un servidor de terceros no vale nada.

¿Y en Claude Code?

La declaración se hace en un comando, con el token dejado en forma de variable para que el archivo se pueda versionar.

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

El archivo .mcp.json escrito en la raíz del proyecto contiene efectivamente ${MCP_TOKEN}, y no el valor del token.

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

Un servidor de ámbito de proyecto no está activo por ello. claude mcp list lo muestra pendiente, y la documentación explica por qué: «Claude Code prompts for approval in interactive sessions before using project-scoped servers from .mcp.json files». Es el comportamiento correcto, un repositorio clonado no debería conectar un servidor por sí solo.

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

El planteamiento es el mismo que para un servidor MCP destinado a WordPress, salvo que WordPress ahora proporciona una API de abilities y un adaptador oficial, mientras que PrestaShop deja el Webservice como base.

Dos trampas que el agente nunca verá

La raíz del Webservice en JSON responde 500. En PrestaShop 9.1.4 con PHP 8.5, GET /api/?output_format=JSON devuelve un 500 cuando la misma raíz en XML devuelve un 200. El defecto 34794 del repositorio de PrestaShop describe exactamente eso, «Uncaught TypeError: array_filter()» en salida JSON, y sigue abierto: reportado el 9 de diciembre de 2023, última actividad el 10 de agosto de 2026. Así que nunca dejes que un agente descubra los recursos disponibles por la raíz JSON, codifica la lista a mano.

El desfase horario es silencioso, y es lo peor. PrestaShop escribe sus fechas en el huso horario de la tienda (PS_TIMEZONE, aquí Europe/Paris), mientras que el PHP que ejecutaba mi servidor funcionaba en UTC. Mi primera versión de orders_summary acotaba la ventana en now() - 30 días: devolvió cero pedidos en una tienda que tenía cinco. Ningún error, ningún aviso, un cero perfectamente creíble, que el agente habría reportado tal cual. La corrección consiste en acotar en días completos.

php
// PrestaShop escribe sus fechas en el huso horario de la tienda (PS_TIMEZONE), no en
// el del proceso PHP que ejecuta este servidor. Por eso se acota en días
// completos, si no, algunas horas de pedidos desaparecen sin explicación.
$from = (new DateTimeImmutable('today -' . $days . ' days'))->format('Y-m-d 00:00:00');
$to   = (new DateTimeImmutable('tomorrow'))->format('Y-m-d 00:00:00');

Es la clase de error más costosa con un agente: la que produce una respuesta plausible. Un servidor MCP que agrega datos debe probarse con datos cuyo recuento conoces de antemano.

Para qué sirve, en la práctica

Tres usos se sostienen con estas cuatro herramientas de solo lectura. La auditoría de fichas primero: get_product ya devuelve la lista de carencias, el agente solo tiene que recorrerlas y agruparlas. La vigilancia de roturas de stock después, con stock_alerts llamado por un agente programado en vez de por un humano que abre el back-office. La preparación de un proyecto de contenido, por último: detectar las cincuenta fichas que hay que retocar es un trabajo de lectura, reescribirlas a escala es otro, propio de un módulo dedicado como WizardAI.

No añadas una quinta herramienta que escriba. El día que la necesites, escribe un segundo servidor, con su clave, su token y su política de aprobación. Dos servidores separados valen más que un servidor cuya mitad de herramientas son peligrosas.

Lo que hay que recordar

  • PrestaShop edita, en efecto, un módulo MCP oficial desde abril de 2026, propietario y ligado a su OAuth, los cuatro proyectos comunitarios encontrados están todos abandonados desde su semana de creación.
  • La seguridad se sostiene en tres capas independientes: clave Webservice limitada a GET sobre nueve recursos, cliente PHP sin método de escritura, token MCP distinto y revocable.
  • Una herramienta debe responder a una pregunta, no retransmitir una API: 1.137 bytes de texto útil frente a 139.350 bytes de XML en bruto para el mismo catálogo.
  • Sin anotación readOnlyHint, Codex se niega a llamar a una herramienta en modo no interactivo, sea cual sea el ajuste de aprobación. Es el protocolo el que desbloquea, no el cliente, pero una anotación sigue siendo una declaración del servidor, no una garantía.
  • Prueba tus agregaciones con datos cuyo recuento conoces: un desfase horario da un cero creíble que nadie comprobará.

Errores frecuentes

Descubrir los recursos por la raíz JSON GET /api/?output_format=JSON responde 500 en PrestaShop 9.1.4 con PHP 8.5 (defecto abierto desde diciembre de 2023) mientras que la misma raíz en XML responde 200. Codifica la lista de recursos a mano en vez de dejar que se descubra.
Acotar una agregación sobre la hora actual PrestaShop escribe sus fechas en PS_TIMEZONE, tu PHP puede estar funcionando en UTC. La ventana now() - 30 días devolvió cero pedidos en una tienda que tenía cinco. Acota en días completos.
Olvidar las anotaciones de herramienta Sin readOnlyHint, Codex rechaza la llamada en modo no interactivo: MCP tool call requires approval, but approval policy is never. Comprobado en cuatro ejecuciones cruzadas: ningún ajuste de default_tools_approval_mode sustituye a la anotación.
Dar a la clave Webservice más que GET Los derechos se ajustan por recurso y por método. Una clave que sabe escribir hace inútil todo el cuidado puesto en el código PHP.
Escribir el token en el comando o en .mcp.json Usa bearer_token_env_var en Codex y ${MCP_TOKEN} en Claude Code, para que el archivo siga siendo versionable.

Claude CodeCodexMCPPHPPrestaShopSécurité

Damien Flandrin Desarrollador web desde 2010, creador de Gekkode y de Email Impact. Cada artículo se prueba en un proyecto real antes de publicarse. Contacto
Newsletter

Las nuevas pruebas, tutoriales y proyectos, por correo.

Pruebas reproducibles, código versionado, resultados fechados. Nunca spam.