
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 fluxGETet leDELETEde fin de session ont disparu, - une méthode
server/discoverremplace la découverte, et les serveurs doivent l’implémenter. - des en-têtes
Mcp-MethodetMcp-Namerecopient des champs du corps, pour qu’un répartiteur de charge route sans lire le JSON. - les réponses de liste transportent
ttlMsetcacheScopepour ê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.
cd docker/articles/2026-09-07/lab/creer-serveur-mcp-php
MCP_TOKEN=demo-token-local php -S 127.0.0.1:8765 mcp.phpOn 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
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.
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 :
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":{}}'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.
$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.
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"}'status=202 octets=0Pourquoi 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.
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.
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"}}}'{"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.
{
"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.
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 :
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":{}}}}'{"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 :
{
"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.
[
'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.
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 ».
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 :
{
"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.
{
"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é :
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"}}}'{
"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.
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 :
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é.
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."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 342Le 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é :
mcp: regexlab/regex_test started
mcp: regexlab/regex_test (failed)
MCP tool call requires approval, but approval policy is neverEn 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 :
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 regexlabBrancher 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.
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 regexlabPour 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 :
{
"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.
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"]'{
"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
Originet 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 fluxGET, 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-MethodetMcp-Name, avec-32020en cas d’écart et-32022pour 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 -Sne sert qu’au développement : un appel réseau lent bloque tout le serveur tant quePHP_CLI_SERVER_WORKERSn’est pas défini.- Pour la production, partez du SDK officiel
mcp/sdkou delaravel/mcpplutôt que de maintenir votre propre couche de transport.
Erreurs fréquentes
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.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.id n'attend pas de réponse. Renvoyez 202 Accepted sans corps, sinon les clients hérités coincent sur notifications/initialized.isError: true et un message exploitable. Une erreur de protocole fait abandonner le modèle, une erreur d'exécution le fait se corriger.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.

