Créer un serveur MCP en PHP sans aucune dépendance

Un serveur MCP conforme à la spécification 2026-07-28, en PHP 8 pur, sans Composer ni framework. Écrit, exécuté et appelé depuis Codex, avec les réponses réelles.

Créer un serveur MCP en PHP sans aucune dépendance
Réponse rapide

Un serveur MCP est un service qui expose des outils à un agent d'IA en JSON-RPC 2.0, décrits par un schéma que le client lit à l'exécution avec tools/list. En PHP, il tient dans un fichier de 380 lignes : une route POST /mcp, un jeton bearer, et une fonction par outil. Attention, la révision 2026-07-28 supprime la poignée de main initialize, mais Codex et MCP Inspector 2.5.0 l'utilisent encore : votre serveur doit gérer les deux.

Vous avez une API interne, un catalogue produit ou une base de logs, et vous aimeriez que Claude Code ou Codex s’en serve directement au lieu de vous demander des copier-coller. C’est le travail d’un serveur MCP, et il tient dans un seul fichier PHP : celui de ce tutoriel fait 380 lignes, sans Composer, sans framework et sans autre dépendance que PHP 8.

Un serveur MCP, c’est quoi ?

Le Model Context Protocol est un protocole ouvert qui standardise la façon dont une application d’IA se branche sur des données et des outils extérieurs. La spécification distingue trois rôles : les hôtes, applications qui lancent les connexions. Les clients, connecteurs à l’intérieur de l’hôte. Les serveurs, services qui fournissent le contexte et les capacités. Les messages sont du JSON-RPC 2.0.

Un serveur peut offrir trois choses : des ressources (du contexte et des données), des prompts (des gabarits de messages) et des outils (des fonctions que le modèle exécute). Nous n’implémentons ici que les outils, parce que c’est ce qui sert dans la plupart des cas et parce que c’est la partie qui se teste au curl.

MCP ou API REST : qu’est-ce qui change vraiment ?

MCP ne remplace pas REST, il ajoute une couche de description au-dessus. Voici ce qui diffère concrètement.

Point API REST classique Serveur MCP
Découverte Une documentation à lire, un OpenAPI à charger tools/list renvoie les schémas JSON de chaque outil, à l’exécution
Appelant Du code que vous écrivez Le modèle, qui choisit l’outil d’après sa description
Format Ce que vous voulez JSON-RPC 2.0, imposé
Erreurs Codes HTTP Deux familles : erreurs de protocole et erreurs d’exécution d’outil
Intégration Un adaptateur par client Un serveur, tous les agents compatibles

Le dernier point est le seul qui compte vraiment. Un serveur MCP écrit une fois se branche sur Claude Code, sur Codex, sur l’Inspector, et sur les autres clients de l’écosystème sans une ligne d’adaptation. C’est la même promesse que pour les skills, dont nous détaillons le format dans le guide du fichier SKILL.md.

Pourquoi votre serveur doit parler deux langues

C’est le piège de ce tutoriel, et il vaut mieux le poser tout de suite. La révision 2026-07-28 de la spécification, publiée le 28 juillet 2026 par David Soria Parra et Den Delimarsky, a supprimé la poignée de main. Plus de initialize, plus de notification notifications/initialized, plus d’en-tête Mcp-Session-Id. Chaque requête transporte désormais sa version de protocole et l’identité du client dans un champ _meta, et le serveur peut être répliqué sans état partagé.

Les autres changements de la même révision touchent directement le transport HTTP :

  • le point d’entrée n’accepte plus que POST, le flux GET et le DELETE de fin de session ont disparu,
  • une méthode server/discover remplace la découverte, et les serveurs doivent l’implémenter.
  • des en-têtes Mcp-Method et Mcp-Name recopient des champs du corps, pour qu’un répartiteur de charge route sans lire le JSON.
  • les réponses de liste transportent ttlMs et cacheScope pour être mises en cache.

Sur le papier, il suffirait donc d’écrire un serveur 2026-07-28. Sauf que j’ai journalisé la méthode d’ouverture et l’en-tête User-Agent de chaque client qui s’est connecté à mon serveur le 7 septembre 2026, et voici ce que j’ai vu.

Client User-Agent Ouverture Version annoncée
Codex codex-mcp-client/0.153.4 initialize 2025-06-18
MCP Inspector 2.5.0 node initialize 2025-11-25

Aucun des deux n’a envoyé de server/discover, et aucun n’a mis de _meta dans ses requêtes. Un serveur qui ne parlerait que la révision de juillet serait aujourd’hui inutilisable avec ces clients. La spécification a prévu le cas : elle appelle dual-era une implémentation qui gère les deux, et autorise explicitement un serveur à servir les deux époques sur le même point d’entrée. C’est ce que nous allons faire, et cela coûte une dizaine de lignes.

Le squelette : un fichier, une route

Le serveur de démonstration s’appelle regexlab et expose deux outils : regex_test, qui teste une expression régulière sur des exemples, et blog_search, qui interroge l’API REST publique de gekkode.com en lecture seule. On le lance avec le serveur web intégré de PHP.

bash
cd docker/articles/2026-09-07/lab/creer-serveur-mcp-php
MCP_TOKEN=demo-token-local php -S 127.0.0.1:8765 mcp.php

On commence par deux fonctions d’enveloppe. Toutes les sorties passent par elles, ce qui garantit qu’aucune réponse ne part sans code HTTP explicite.

php
<?php
declare(strict_types=1);

const SERVER_NAME = 'regexlab';
const SERVER_VERSION = '0.1.0';
const SUPPORTED_VERSIONS = ['2026-07-28', '2025-11-25', '2025-06-18'];
const ALLOWED_ORIGINS = ['http://127.0.0.1:8765', 'http://localhost:8765'];

function send(int $status, ?array $payload = null): never
{
    http_response_code($status);
    if ($payload === null) {
        exit;
    }
    header('Content-Type: application/json');
    echo json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), "\n";
    exit;
}

function fail(int $status, int $code, string $message, mixed $id = null, ?array $data = null): never
{
    $error = ['code' => $code, 'message' => $message];
    if ($data !== null) {
        $error['data'] = $data;
    }
    send($status, ['jsonrpc' => '2.0', 'id' => $id, 'error' => $error]);
}

Vient ensuite la porte d’entrée : méthode HTTP, chemin, origine, jeton. Quatre contrôles, et les codes de retour que la spécification impose.

php
function header_value(string $name): ?string
{
    $key = 'HTTP_' . strtoupper(str_replace('-', '_', $name));
    return isset($_SERVER[$key]) ? trim((string) $_SERVER[$key]) : null;
}

// GET et DELETE ne font plus partie de la révision 2026-07-28.
if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST') {
    header('Allow: POST');
    send(405, ['jsonrpc' => '2.0', 'error' => ['code' => -32600, 'message' => 'Use POST on /mcp']]);
}

if (parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) !== '/mcp') {
    fail(404, -32601, 'Unknown endpoint');
}

// Origin : défense contre le DNS rebinding, 403 exigé par la spécification.
$origin = header_value('Origin');
if ($origin !== null && !in_array($origin, ALLOWED_ORIGINS, true)) {
    fail(403, -32600, 'Origin not allowed');
}

// Jeton bearer.
$expected = getenv('MCP_TOKEN') ?: '';
if ($expected !== '') {
    $sent = (string) preg_replace('/^Bearer\s+/i', '', header_value('Authorization') ?? '');
    if (!hash_equals($expected, $sent)) {
        header('WWW-Authenticate: Bearer realm="regexlab"');
        fail(401, -32001, 'Unauthorized');
    }
}

La validation de l’en-tête Origin n’est pas décorative. La spécification la classe en MUST et exige un 403, précisément parce que sans elle un site web ouvert dans votre navigateur peut, par rebinding DNS, dialoguer avec le serveur MCP qui tourne sur votre machine. Vérifions les trois portes :

bash
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8765/mcp
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://127.0.0.1:8765/mcp \
  -H 'Origin: https://evil.example' -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
curl -s -X POST http://127.0.0.1:8765/mcp -H 'Content-Type: application/json' \
  -H 'MCP-Protocol-Version: 2026-07-28' -H 'Mcp-Method: server/discover' \
  -d '{"jsonrpc":"2.0","id":1,"method":"server/discover","params":{}}'
json
405
403
{"jsonrpc":"2.0","id":null,"error":{"code":-32001,"message":"Unauthorized"}}

Lire la requête et repérer l’époque

Le corps est du JSON-RPC. Trois informations en sortent : la méthode, les paramètres, l’identifiant. La présence de io.modelcontextprotocol/protocolVersion dans _meta suffit à savoir si le client est moderne.

php
$raw = file_get_contents('php://input') ?: '';
try {
    $req = json_decode($raw, true, 32, JSON_THROW_ON_ERROR);
} catch (JsonException) {
    fail(400, -32700, 'Parse error');
}
if (!is_array($req) || !isset($req['method'])) {
    fail(400, -32600, 'Invalid Request');
}

$method = (string) $req['method'];
$params = is_array($req['params'] ?? null) ? $req['params'] : [];
$id = $req['id'] ?? null;

$meta = is_array($params['_meta'] ?? null) ? $params['_meta'] : [];
$bodyVersion = $meta['io.modelcontextprotocol/protocolVersion'] ?? null;
$modern = is_string($bodyVersion);

error_log(sprintf(
    '[mcp] %s | version=%s | agent=%s',
    $method,
    is_string($bodyVersion) ? $bodyVersion : ($params['protocolVersion'] ?? '-'),
    $_SERVER['HTTP_USER_AGENT'] ?? '-'
));

// Une notification n'a pas d'identifiant : on accuse réception et on s'arrête.
if (!array_key_exists('id', $req)) {
    send(202, null);
}

L’appel à error_log ci-dessus est ce qui m’a permis de dresser le tableau des clients plus haut. Le serveur intégré de PHP écrit sur la sortie d’erreur, donc le journal apparaît dans le terminal qui l’a lancé. Gardez-les pendant tout le développement.

Le traitement des notifications mérite un mot. Une notification JSON-RPC est un message sans id : le client n’attend pas de réponse. La spécification est catégorique, le serveur doit répondre 202 Accepted sans corps. C’est par là que passe le notifications/initialized des clients hérités.

bash
curl -s -o /dev/null -w "status=%{http_code} octets=%{size_download}\n" \
  -X POST http://127.0.0.1:8765/mcp -H 'Authorization: Bearer demo-token-local' \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
bash
status=202 octets=0

Pourquoi ce serveur ne renvoie que du JSON

Un mot sur le format de réponse, parce que c’est un choix et non une obligation. Face à une requête, le serveur peut répondre soit un objet unique en Content-Type: application/json, soit un flux SSE en text/event-stream, propre à cette requête, qui transporte des notifications avant la réponse finale. Le client, lui, doit savoir lire les deux : c’est la raison de l’en-tête Accept: application/json, text/event-stream qu’il envoie systématiquement.

Ce serveur s’en tient au JSON. C’est le plus simple, et cela suffit tant qu’aucun outil n’a besoin de rendre compte de son avancement. Le flux devient nécessaire dans deux cas : un outil long qui veut émettre des notifications/progress pendant son travail, et la requête subscriptions/listen, dont la réponse reste ouverte pour porter les changements de liste. Le jour venu, la révision 2026-07-28 fait de la fermeture du flux par le client le signal d’annulation de la requête, et recommande l’en-tête X-Accel-Buffering: no pour que nginx ne mette pas les événements en tampon.

Valider les en-têtes miroir

C’est la partie la plus spécifique à la révision 2026-07-28, et celle qu’on oublie. Quand un client moderne poste une requête, il doit recopier trois valeurs du corps dans des en-têtes : MCP-Protocol-Version, Mcp-Method, et Mcp-Name pour un tools/call. Le serveur doit vérifier que l’en-tête et le corps concordent, et rejeter en 400 avec le code d’erreur -32020 sinon.

La raison est une faille classique : si un répartiteur de charge décide du routage sur l’en-tête pendant que le serveur exécute d’après le corps, un client malveillant peut faire passer un appel pour un autre. La spécification appelle cette erreur HeaderMismatch.

php
if ($modern) {
    $headerVersion = header_value('MCP-Protocol-Version');
    if ($headerVersion !== $bodyVersion) {
        fail(400, -32020, sprintf(
            'Header mismatch: MCP-Protocol-Version %s does not match body value %s',
            var_export($headerVersion, true),
            var_export($bodyVersion, true)
        ), $id);
    }
    if (header_value('Mcp-Method') !== $method) {
        fail(400, -32020, 'Header mismatch: Mcp-Method', $id);
    }
    if ($method === 'tools/call' && header_value('Mcp-Name') !== ($params['name'] ?? null)) {
        fail(400, -32020, 'Header mismatch: Mcp-Name', $id);
    }
    if (!in_array($bodyVersion, SUPPORTED_VERSIONS, true)) {
        fail(400, -32022, 'Unsupported protocol version', $id, [
            'supported' => SUPPORTED_VERSIONS,
            'requested' => $bodyVersion,
        ]);
    }
}

Un test qui ment sur Mcp-Name : l’en-tête annonce blog_search, le corps demande regex_test.

bash
curl -s -X POST http://127.0.0.1:8765/mcp \
  -H 'Authorization: Bearer demo-token-local' -H 'Content-Type: application/json' \
  -H 'MCP-Protocol-Version: 2026-07-28' -H 'Mcp-Method: tools/call' \
  -H 'Mcp-Name: blog_search' \
  -d '{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"regex_test","arguments":{"pattern":"a","subjects":["a"]},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28"}}}'
json
{"jsonrpc":"2.0","id":5,"error":{"code":-32020,"message":"Header mismatch: Mcp-Name"}}

Et une version que le serveur ne connaît pas. La spécification impose de répondre -32022 en listant les versions acceptées, pour que le client puisse réessayer sans intervention humaine.

json
{
  "jsonrpc": "2.0",
  "id": 6,
  "error": {
    "code": -32022,
    "message": "Unsupported protocol version",
    "data": {
      "supported": ["2026-07-28", "2025-11-25", "2025-06-18"],
      "requested": "1900-01-01"
    }
  }
}

Répondre à la découverte, des deux côtés

Voilà ce qui fait le serveur bi-époque : un case pour initialize, un pour server/discover, et les deux renvoient les mêmes capacités dans deux emballages différents. Le premier est la dizaine de lignes qu’il en coûte pour rester compatible.

php
switch ($method) {
    // Poignée de main héritée (révisions 2025-11-25 et antérieures).
    case 'initialize':
        $asked = (string) ($params['protocolVersion'] ?? '2025-11-25');
        ok($id, [
            'protocolVersion' => in_array($asked, SUPPORTED_VERSIONS, true) ? $asked : '2025-11-25',
            'capabilities' => ['tools' => ['listChanged' => false]],
            'serverInfo' => ['name' => SERVER_NAME, 'version' => SERVER_VERSION],
            'instructions' => 'regex_test teste une expression régulière ; blog_search interroge gekkode.com.',
        ]);

    // Découverte sans état (révision 2026-07-28).
    case 'server/discover':
        ok($id, [
            'resultType' => 'complete',
            'supportedVersions' => SUPPORTED_VERSIONS,
            'capabilities' => ['tools' => ['listChanged' => false]],
            '_meta' => ['io.modelcontextprotocol/serverInfo' => [
                'name' => SERVER_NAME,
                'version' => SERVER_VERSION,
            ]],
            'instructions' => 'regex_test teste une expression régulière ; blog_search interroge gekkode.com.',
            'ttlMs' => 3600000,
            'cacheScope' => 'public',
        ]);

    default:
        fail($modern ? 404 : 200, -32601, 'Method not found: ' . $method, $id);
}

Deux détails à ne pas rater. Le serverInfo se déplace : il est à la racine du résultat en mode hérité, et sous _meta['io.modelcontextprotocol/serverInfo'] en 2026-07-28. Et la méthode inconnue ne se traite pas pareil : en mode moderne, la spécification demande un 404 Not Found accompagné d’un -32601, parce que le corps JSON-RPC est ce qui distingue ce cas du 404 d’un vieux serveur qui n’héberge pas le point d’entrée.

Voici la vraie réponse à server/discover :

bash
curl -s -X POST http://127.0.0.1:8765/mcp \
  -H 'Authorization: Bearer demo-token-local' -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -H 'MCP-Protocol-Version: 2026-07-28' -H 'Mcp-Method: server/discover' \
  -d '{"jsonrpc":"2.0","id":"d1","method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"curl","version":"8.7.1"},"io.modelcontextprotocol/clientCapabilities":{}}}}'
json
{"jsonrpc":"2.0","id":"d1","result":{"resultType":"complete","supportedVersions":["2026-07-28","2025-11-25","2025-06-18"],"capabilities":{"tools":{"listChanged":false}},"_meta":{"io.modelcontextprotocol/serverInfo":{"name":"regexlab","version":"0.1.0"}},"instructions":"regex_test teste une expression régulière ; blog_search interroge gekkode.com.","ttlMs":3600000,"cacheScope":"public"}}

Et celle à initialize, telle que Codex la reçoit :

json
{
  "jsonrpc": "2.0",
  "id": 0,
  "result": {
    "protocolVersion": "2025-11-25",
    "capabilities": {"tools": {"listChanged": false}},
    "serverInfo": {"name": "regexlab", "version": "0.1.0"},
    "instructions": "regex_test teste une expression régulière ; blog_search interroge gekkode.com."
  }
}

Décrire les outils : tools/list

Un outil, c’est un nom, une description, et un schéma JSON d’entrée. La description est ce que le modèle lit pour décider s’il appelle l’outil : écrivez-la pour lui, pas pour un développeur. Le outputSchema est facultatif mais recommandé, il permet au client de valider ce que vous renvoyez.

php
[
    'name' => 'regex_test',
    'title' => 'Testeur d\'expression régulière',
    'description' => 'Teste une expression régulière PCRE sur une liste de chaînes et retourne, '
        . 'pour chacune, si elle correspond et les groupes capturés.',
    'inputSchema' => [
        'type' => 'object',
        'properties' => [
            'pattern' => ['type' => 'string', 'description' => 'Motif PCRE, sans délimiteurs.'],
            'flags' => ['type' => 'string', 'description' => 'Modificateurs parmi i, m, s, x, u.'],
            'subjects' => [
                'type' => 'array',
                'items' => ['type' => 'string'],
                'description' => 'Chaînes à tester (20 au maximum).',
            ],
        ],
        'required' => ['pattern', 'subjects'],
        'additionalProperties' => false,
    ],
]

La réponse à tools/list ajoute resultType, et les deux champs de cache introduits par la révision de juillet.

php
case 'tools/list':
    ok($id, [
        'resultType' => 'complete',
        'tools' => tool_definitions(),
        'ttlMs' => 300000,
        'cacheScope' => 'public',
    ]);

Trois règles de nommage à respecter : le nom d’un outil doit tenir entre 1 et 128 caractères, se limiter aux lettres ASCII, chiffres, _, - et ., et être unique dans le serveur. Les espaces et les virgules sont proscrits.

Exécuter un outil : tools/call et ses deux familles d’erreurs

La qualité d’un serveur MCP se joue ici, et beaucoup de tutoriels passent à côté. La spécification distingue deux mécanismes de remontée d’erreur, et les confondre coûte des allers-retours au modèle :

  • une erreur de protocole (outil inconnu, requête malformée) est une erreur JSON-RPC classique, le modèle a peu de chances de s’en sortir seul,
  • une erreur d’exécution (argument hors bornes, API en panne, date invalide) se renvoie dans un résultat normal avec isError: true, et le client doit la donner au modèle pour qu’il se corrige.

Le testeur d’expressions régulières est un cas d’école : un motif mal écrit doit revenir au modèle avec le message de PCRE, pas avec un « erreur 500 ».

php
function run_regex_test(array $args): array
{
    $pattern = (string) ($args['pattern'] ?? '');
    $flags = (string) ($args['flags'] ?? '');
    $subjects = is_array($args['subjects'] ?? null) ? array_values($args['subjects']) : [];

    if ($pattern === '' || strlen($pattern) > 512) {
        return tool_error('Le motif doit faire entre 1 et 512 caractères.');
    }
    if ($flags !== '' && !preg_match('/^[imsxu]{1,5}$/', $flags)) {
        return tool_error('Modificateurs acceptés : i, m, s, x, u.');
    }
    if ($subjects === [] || count($subjects) > 20) {
        return tool_error('Fournissez entre 1 et 20 chaînes à tester.');
    }

    // On échappe les délimiteurs non protégés plutôt que d'accepter un motif déjà délimité.
    $delimited = '/' . preg_replace('~(?<!\\\\)/~', '\\/', $pattern) . '/' . $flags;
    $compileError = null;
    set_error_handler(static function (int $no, string $msg) use (&$compileError): bool {
        $compileError = $msg;
        return true;
    });
    $compiles = preg_match($delimited, '');
    restore_error_handler();
    if ($compiles === false) {
        // Le message de PCRE dit où le motif casse : le modèle peut se corriger seul.
        return tool_error('Motif invalide : ' . ($compileError ?? preg_last_error_msg()));
    }
    // …
}

Deux précautions dans ces quelques lignes. On n’accepte jamais un motif déjà délimité, on ajoute soi-même les délimiteurs en échappant les / non protégés. Accepter /motif/flags tel quel reviendrait à laisser l’appelant choisir les modificateurs, ce que la validation en amont interdit. Et le gestionnaire d’erreur temporaire récupère le message exact de PCRE, là où preg_last_error_msg() se contente d’un laconique « Internal error ».

La différence se voit à l’appel. Motif avec une parenthèse manquante :

json
{
  "jsonrpc": "2.0",
  "id": 8,
  "result": {
    "resultType": "complete",
    "content": [
      {
        "type": "text",
        "text": "Motif invalide : preg_match(): Compilation failed: missing closing parenthesis at offset 7"
      }
    ],
    "isError": true
  }
}

Un modèle qui reçoit ce texte referme la parenthèse et rappelle l’outil. S’il avait reçu une erreur JSON-RPC, il aurait eu bien moins de chances de s’en sortir : la spécification note que les erreurs de protocole aboutissent rarement à une correction.

Il reste un risque propre aux expressions régulières : l’explosion combinatoire. Un motif comme (a+)+$ sur une chaîne de trente-six « a » suivie d’un « b » occupe le processeur pendant très longtemps. La parade tient en une ligne en tête de fichier, ini_set('pcre.backtrack_limit', '200000');, et en un test du retour de preg_match.

json
{
  "jsonrpc": "2.0",
  "id": 9,
  "result": {
    "resultType": "complete",
    "content": [{"type": "text", "text": "Échec du moteur PCRE : Backtrack limit exhausted"}],
    "isError": true
  }
}

Un appel qui réussit, maintenant, avec son structuredContent conforme au outputSchema déclaré :

bash
curl -s -X POST http://127.0.0.1:8765/mcp \
  -H 'Authorization: Bearer demo-token-local' -H 'Content-Type: application/json' \
  -H 'MCP-Protocol-Version: 2026-07-28' -H 'Mcp-Method: tools/call' \
  -H 'Mcp-Name: regex_test' \
  -d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"regex_test","arguments":{"pattern":"^([A-Z]{2})-(\\d{4})$","subjects":["FR-2026","fr-2026","XX-12"]},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28"}}}'
json
{
  "jsonrpc": "2.0",
  "id": 3,
  "result": {
    "resultType": "complete",
    "content": [
      {
        "type": "text",
        "text": "1 correspondance(s) sur 3 chaîne(s).\nOUI FR-2026 -> FR | 2026\nNON fr-2026\nNON XX-12"
      }
    ],
    "structuredContent": {
      "pattern": "^([A-Z]{2})-(\\d{4})$",
      "matches": 1,
      "results": [
        {"subject": "FR-2026", "matched": true, "groups": ["FR", "2026"]},
        {"subject": "fr-2026", "matched": false, "groups": []},
        {"subject": "XX-12", "matched": false, "groups": []}
      ]
    },
    "isError": false
  }
}

Notez que le texte lisible est présent en plus du contenu structuré. La spécification le recommande explicitement : un outil qui renvoie du structuré devrait aussi fournir la version sérialisée dans un bloc de texte, pour les clients qui ne lisent que content.

Le second outil : interroger une API en lecture seule

blog_search montre le cas le plus courant en entreprise, exposer un service existant. L’hôte est codé en dur, la méthode est un GET, aucun paramètre de l’appelant ne construit d’URL arbitraire.

php
function run_blog_search(array $args): array
{
    $query = trim((string) ($args['query'] ?? ''));
    $limit = max(1, min(5, (int) ($args['limit'] ?? 3)));
    if ($query === '' || mb_strlen($query) > 120) {
        return tool_error('La requête doit faire entre 1 et 120 caractères.');
    }

    $url = BLOG_API . '?' . http_build_query([
        'search' => $query,
        'per_page' => $limit,
        'orderby' => 'relevance',
        '_fields' => 'id,date,link,title',
    ]);
    $context = stream_context_create(['http' => [
        'method' => 'GET',
        'timeout' => 8,
        'header' => "Accept: application/json\r\nUser-Agent: regexlab-mcp/0.1\r\n",
        'ignore_errors' => true,
    ]]);
    $body = @file_get_contents($url, false, $context);
    if ($body === false) {
        return tool_error('API du blog injoignable.');
    }
    // …
}

Le _fields de l’API REST de WordPress fait beaucoup de travail : il réduit la réponse aux quatre champs utiles, donc les tokens facturés au passage dans le contexte du modèle. C’est le même réflexe que celui décrit dans notre article sur la réduction des tokens, appliqué au serveur plutôt qu’au client. Résultat de l’appel :

bash
2024-08-11 — Les 10 meilleurs packages Laravel pour créer votre site web
Les 10 meilleurs packages Laravel pour créer votre site web
2023-02-03 — Comment installer Redis sur Debian et Laravel https://www.gekkode.com/developpement/comment-installer-redis-sur-debian-et-laravel/

Brancher le serveur sur Codex

Codex lit ses serveurs MCP dans ~/.codex/config.toml, mais l’option -c permet de les déclarer pour une seule exécution, sans rien écrire sur le disque. Idéal pour un test, et c’est par là que le serveur a été vérifié.

bash
export REGEXLAB_TOKEN=demo-token-local

codex exec \
  -c 'mcp_servers.regexlab.url="http://127.0.0.1:8765/mcp"' \
  -c 'mcp_servers.regexlab.bearer_token_env_var="REGEXLAB_TOKEN"' \
  -c 'mcp_servers.regexlab.default_tools_approval_mode="approve"' \
  -m gpt-6-astra --skip-git-repo-check \
  "Utilise UNIQUEMENT l'outil MCP regexlab.regex_test. Teste le motif ^([A-Z]{2})-(\d{4})\$ sur les chaînes FR-2026, fr-2026 et XX-12."
bash
codex
Je vais tester ce motif sur les trois chaînes avec l'outil demandé.

mcp: regexlab/regex_test started
mcp: regexlab/regex_test (completed)
codex
Il y a 1 correspondance sur 3 : « FR-2026 », avec les groupes capturés « FR » et « 2026 ».
tokens used
9 342

Le troisième -c est celui qui m’a coûté deux essais. Sans lui, Codex se connecte au serveur, liste les outils, et refuse l’appel avec un message sans ambiguïté :

bash
mcp: regexlab/regex_test started
mcp: regexlab/regex_test (failed)
MCP tool call requires approval, but approval policy is never

En mode exec, la politique d’approbation vaut never et aucun humain n’est là pour valider. La clé default_tools_approval_mode accepte quatre valeurs, auto, prompt, writes et approve, seule la dernière laisse passer l’appel sans intervention. À réserver aux serveurs que vous avez écrits vous-même, pour les raisons détaillées dans notre article sur les sandbox et les permissions.

Pour une installation durable, la commande officielle écrit la même chose dans la configuration :

bash
codex mcp add regexlab --url http://127.0.0.1:8765/mcp \
  --bearer-token-env-var REGEXLAB_TOKEN
codex mcp list
codex mcp get regexlab
codex mcp remove regexlab

Brancher le serveur sur Claude Code

Côté Claude Code, l’ajout d’un serveur HTTP tient en une commande, et l’en-tête d’autorisation se passe avec --header.

bash
claude mcp add --transport http regexlab http://127.0.0.1:8765/mcp \
  --header "Authorization: Bearer demo-token-local"
claude mcp list
claude mcp get regexlab

Pour partager le serveur avec l’équipe, la portée project écrit un fichier .mcp.json à la racine du dépôt, versionnable. La documentation accepte l’expansion de variables d’environnement, ce qui évite de commiter le jeton :

json
{
  "mcpServers": {
    "regexlab": {
      "type": "http",
      "url": "http://127.0.0.1:8765/mcp",
      "headers": {
        "Authorization": "Bearer ${REGEXLAB_TOKEN}"
      }
    }
  }
}

La syntaxe ${VAR} et ${VAR:-valeur par défaut} est reconnue dans les champs url, headers, command, args et env. Depuis la session, /mcp affiche l’état des serveurs et permet de se connecter à ceux qui demandent OAuth.

Vérifier avec MCP Inspector, sans rien installer

L’outil de test officiel s’utilise en mode graphique, mais son mode --cli est bien plus pratique pour un test rapide et se lance par npx, donc sans installation globale.

bash
npx -y @modelcontextprotocol/inspector@2.5.0 --cli http://127.0.0.1:8765/mcp \
  --transport http --header "Authorization: Bearer demo-token-local" \
  --method tools/call --tool-name regex_test \
  --tool-arg 'pattern=^\d{5}$' --tool-arg 'subjects=["62500","6250"]'
json
{
  "content": [
    {"type": "text", "text": "1 correspondance(s) sur 2 chaîne(s).\nOUI 62500\nNON 6250"}
  ],
  "structuredContent": {
    "pattern": "^\\d{5}$",
    "matches": 1,
    "results": [
      {"subject": "62500", "matched": true, "groups": []},
      {"subject": "6250", "matched": false, "groups": []}
    ]
  },
  "isError": false
}

L’Inspector 2.5.0, publié le 2 septembre 2026, ouvre lui aussi par un initialize, en annonçant la version 2025-11-25. Il reste l’outil le plus rapide pour voir ce que votre serveur renvoie vraiment, avant d’y brancher un agent.

Ce que le serveur intégré de PHP ne sait pas faire

php -S traite une requête à la fois. Tant que vos outils répondent en quelques millisecondes, cela ne se voit pas. Dès qu’un outil appelle le réseau, tout le serveur se bloque. J’ai lancé deux appels en parallèle, blog_search qui interroge l’API du blog puis regex_test qui ne fait que du calcul local :

Configuration blog_search regex_test lancé 50 ms après
php -S par défaut 0,601 s 0,548 s
PHP_CLI_SERVER_WORKERS=4 0,578 s 0,002 s

Sans processus supplémentaires, l’appel rapide attend sagement la fin de l’appel lent : 0,548 s au lieu des 13 ms qu’il met seul. Avec quatre travailleurs, il répond immédiatement. C’est suffisant pour développer, cela ne remplace pas PHP-FPM derrière nginx en production.

Passer en production : le SDK PHP officiel et Laravel MCP

Écrire son serveur à la main est le bon moyen de comprendre le protocole. Pour du code qui vit, deux paquets méritent le détour, et tous deux ont bougé cet été.

Le SDK PHP officiel s’installe par composer require mcp/sdk. Il se présente comme le SDK officiel du protocole pour PHP, maintenu en collaboration avec la Fondation PHP et en reprenant les pratiques du projet Symfony. Il est framework-agnostique, demande PHP 8.1 au minimum, et son dépôt annonce la prise en charge des deux époques de protocole, la poignée de main initialize et la révision sans état 2026-07-28. Dernière version au 7 septembre 2026 : la v0.8.1, publiée le 29 août.

Laravel MCP (composer require laravel/mcp) vise l’autre bout du spectre : vous déclarez vos serveurs dans routes/ai.php, avec Mcp::web('/mcp/weather', WeatherServer::class) pour un serveur HTTP ou Mcp::local('weather', …) pour une commande Artisan, et chaque outil devient une classe qui étend Tool avec une méthode handle() et un schema(). Le middleware Laravel s’applique tel quel, y compris throttle. La première version stable est en préparation : la v1.0.0-beta.1 date du 14 août 2026 et demande PHP 8.2 avec Laravel 11.45.3, 12.41.1 ou 13.

Si votre besoin est WordPress ou PrestaShop plutôt qu’un serveur générique, deux articles de cette série traitent le cas : MCP pour WordPress et MCP pour PrestaShop.

Cinq lignes de sécurité qui ne coûtent rien

Un serveur MCP est une surface d’exécution de code accessible par un modèle. La spécification consacre une page entière au sujet, retenez au minimum ces règles, toutes tenues par le fichier de ce tutoriel.

  • Écoutez sur 127.0.0.1, jamais sur 0.0.0.0, pour un serveur local. La spécification le classe en SHOULD.
  • Validez l’en-tête Origin et répondez 403 quand il ne figure pas dans votre liste. Sans cela, une page web ouverte dans votre navigateur peut parler à votre serveur.
  • Exigez un jeton, comparé avec hash_equals() pour ne pas fuiter d’information par le temps de comparaison.
  • Restez en lecture seule tant que vous n’avez pas besoin d’écrire, et bornez tout : longueur des chaînes, nombre d’éléments, délai d’attente réseau, limite de backtracking.
  • Codez l’hôte en dur dans les outils qui appellent le réseau. Un paramètre d’URL libre transforme votre serveur en relais pour l’exfiltration.

Ce dernier point n’est pas théorique. Les incidents publics de l’écosystème MCP recensés jusqu’ici portent presque tous sur des serveurs qui en faisaient plus qu’annoncé : un paquet publié sur npm qui recopiait au passage les e-mails qu’il était censé envoyer, un relais dont une faille ouvrait l’exécution de commandes. Un outil incapable de faire autre chose que ce que dit sa description est un outil qu’on peut auditer. La question du périmètre d’un agent et de ses outils est traitée en détail dans l’article sur les sandbox et les permissions.

Ce qu’il faut retenir

  • Un serveur MCP utile tient dans un fichier PHP de 380 lignes : une route POST /mcp, du JSON-RPC 2.0, et deux fonctions par outil.
  • La révision 2026-07-28 supprime initialize, les sessions et le flux GET, mais Codex et l’Inspector ouvrent encore par la poignée de main héritée : écrivez un serveur qui gère les deux époques.
  • En mode moderne, recopiez et vérifiez MCP-Protocol-Version, Mcp-Method et Mcp-Name, avec -32020 en cas d’écart et -32022 pour une version inconnue.
  • Renvoyez les erreurs métier dans le résultat avec isError: true, pas en erreur JSON-RPC : c’est ce qui permet au modèle de se corriger seul.
  • Testez au curl, puis avec npx @modelcontextprotocol/inspector --cli, avant de brancher un agent.
  • php -S ne sert qu’au développement : un appel réseau lent bloque tout le serveur tant que PHP_CLI_SERVER_WORKERS n’est pas défini.
  • Pour la production, partez du SDK officiel mcp/sdk ou de laravel/mcp plutôt que de maintenir votre propre couche de transport.

Erreurs fréquentes

Écrire un serveur qui ne parle que la révision 2026-07-28 Les clients testés le 7 septembre 2026 ouvrent encore par initialize. Gardez le cas initialize à côté de server/discover : la spécification autorise un serveur à servir les deux époques sur le même point d'entrée.
Oublier de valider les en-têtes miroir En 2026-07-28, MCP-Protocol-Version, Mcp-Method et Mcp-Name doivent correspondre au corps. Un écart se rejette en 400 avec le code -32020, sinon un intermédiaire et le serveur peuvent lire deux choses différentes.
Répondre à une notification Un message sans id n'attend pas de réponse. Renvoyez 202 Accepted sans corps, sinon les clients hérités coincent sur notifications/initialized.
Remonter une erreur métier en erreur JSON-RPC Un argument invalide se renvoie dans un résultat avec isError: true et un message exploitable. Une erreur de protocole fait abandonner le modèle, une erreur d'exécution le fait se corriger.
Appeler l'outil depuis codex exec sans régler l'approbation La politique vaut never et Codex refuse avec « MCP tool call requires approval ». Ajoutez -c 'mcp_servers.NOM.default_tools_approval_mode="approve"', et seulement pour un serveur que vous avez écrit.

Claude CodeCodexLaravelMCPPHP

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.