Le serveur MCP minimal en PHP répond un timestamp codé en dur. Utile pour comprendre le protocole, inutile en pratique — un agent veut interroger de vraies données. Passer d'un tool jouet à un tool branché sur MySQL change la nature du risque : on ne sert plus une chaîne statique, on laisse un LLM déclencher des requêtes SQL. Après le point sur l'état de l'écosystème MCP, revenons au concret : comment exposer une base sans rouvrir la porte qu'on vient de fermer.

Le piège du tool "SQL libre"

La tentation la plus rapide : un tool run_query(sql) qui exécute tel quel ce que le modèle envoie. Ça marche en démo, et c'est une catastrophe en prod. Le modèle ne fabrique pas forcément la requête lui-même — il peut la reprendre d'un contenu qu'il vient de lire (un ticket, un email, une page web), exactement le scénario du confused deputy : le serveur a les droits, le LLM ne fait qu'exécuter une instruction qu'il croit légitime. Un run_query generic transforme n'importe quelle injection de prompt en accès SQL arbitraire.

L'architecture qui tient

Deux décisions suffisent à fermer l'essentiel de la surface d'attaque :

  • Un utilisateur MySQL dédié, lecture seule, avec des privilèges scopés aux tables réellement utiles — jamais le compte applicatif principal.
  • Des tools à forme fixe, pas de SQL libre : chaque tool encapsule une requête préparée avec des paramètres typés. Le modèle choisit des valeurs, jamais la structure de la requête.

C'est la même logique que le tableau de contrôles vu dans l'article sur la sécurisation d'un serveur MCP : scoper chaque outil à un périmètre minimal. Ici, le périmètre, c'est le schéma SQL lui-même.

LLM tools/call Tool scopé query_articles(cat, limit) requête préparée MySQL user lecture seule GRANT SELECT pas de SQL libre ici
Le modèle choisit des paramètres (catégorie, limite), jamais la structure de la requête. La base ne voit passer que des requêtes préparées, via un compte qui ne peut de toute façon rien écrire.

Le code

On reprend le squelette du serveur minimal et on ajoute un tool query_articles branché sur PDO :

<?php
$pdo = new PDO(
    'mysql:host=127.0.0.1;dbname=blog;charset=utf8mb4',
    'mcp_readonly',
    getenv('MCP_DB_PASSWORD'),
    [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);

$tools = [
    'query_articles' => [
        'description' => 'Liste les derniers articles publiés, filtrables par catégorie',
        'inputSchema' => [
            'type' => 'object',
            'properties' => [
                'category' => ['type' => 'string', 'enum' => ['php', 'python', 'concepts']],
                'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 20],
            ],
        ],
        'handler' => function (array $args) use ($pdo) {
            $limit = min((int) ($args['limit'] ?? 10), 20);
            $sql = 'SELECT title, slug, creationDate FROM post
                    WHERE category = :cat OR :cat IS NULL
                    ORDER BY creationDate DESC LIMIT :lim';
            $stmt = $pdo->prepare($sql);
            $stmt->bindValue(':cat', $args['category'] ?? null);
            $stmt->bindValue(':lim', $limit, PDO::PARAM_INT);
            $stmt->execute();
            return $stmt->fetchAll(PDO::FETCH_ASSOC);
        },
    ],
];
// Le reste de la boucle stdin/stdout est identique au serveur minimal.

Deux points qui font la différence avec un tool jouet : limit est plafonné côté PHP même si le modèle en demande plus (on ne fait pas confiance à inputSchema pour appliquer une contrainte — c'est une indication pour le client, pas un garde-fou serveur), et la requête reste une forme fixe avec des placeholders, jamais une concaténation de chaîne.

Le compte MySQL dédié

CREATE USER 'mcp_readonly'@'localhost' IDENTIFIED BY '...';
GRANT SELECT ON blog.post TO 'mcp_readonly'@'localhost';
GRANT SELECT ON blog.article TO 'mcp_readonly'@'localhost';
FLUSH PRIVILEGES;

Même si un bug applicatif permettait d'injecter du SQL malgré la requête préparée, ce compte ne peut ni écrire, ni lire les tables hors périmètre (pas de user, pas de link). C'est une deuxième ligne de défense qui ne coûte rien.

Ce qui reste à faire pour la prod

  • Timeout de requête (MYSQL_ATTR_TIMEOUT) pour éviter qu'un paramètre mal formé bloque le process.
  • Un tool par forme de requête utile plutôt qu'un tool générique paramétrable à l'infini — plus un tool est flexible, plus il se rapproche du run_query à éviter.
  • Logs des appels (table, paramètres, durée) séparés du protocole MCP, comme évoqué pour le serveur minimal.

FAQ

Pourquoi ne pas juste valider le SQL envoyé par le modèle avant de l'exécuter ?

Parce qu'une validation syntaxique de SQL libre est un problème ouvert (sous-requêtes, fonctions, encodages) alors qu'un tool à forme fixe avec des paramètres typés élimine la classe de vulnérabilité entièrement, sans avoir à la détecter.

Un utilisateur MySQL en lecture seule suffit-il sans requêtes préparées ?

Non — sans requêtes préparées, une injection SQL peut toujours faire fuiter des données hors du périmètre attendu même avec des droits SELECT uniquement (jointures non prévues, colonnes sensibles). Les deux contrôles sont complémentaires, pas substituables.

Faut-il exposer ça en tool ou en resource MCP ?

En tool : la requête prend des paramètres fournis par le modèle, ce qui exclut la resource par définition.