Construire un serveur MCP minimal en Symfony
Ce tutoriel vous fait construire, de zéro, un serveur MCP (Model Context Protocol) minimal en PHP/Symfony. À la fin, vous aurez un serveur qui parle à un assistant IA et lui expose un outil que vous avez écrit.
Public visé : développeur Symfony à l'aise avec PHP, débutant sur MCP. Prérequis : PHP en ligne de commande, un projet Symfony (ou même un simple script PHP pour commencer).
Nous suivons un seul chemin, le plus court qui fonctionne. Les raffinements de production (validation stricte, registre de services, sécurité) sont signalés mais laissés de côté pour ne pas vous perdre.
Ce que vous allez construire
Un serveur qui communique via le transport stdio (entrée/sortie standard) et répond à trois messages MCP :
initialize— la poignée de main : le serveur annonce ses capacités,tools/list— le catalogue des outils disponibles,tools/call— l'exécution d'un outil.
Vous exposerez un seul outil pour commencer : ping, qui renvoie en écho le message reçu. C'est volontairement trivial : l'objectif est de faire tourner la mécanique MCP de bout en bout. Une fois qu'elle marche, brancher un vrai outil devient une formalité.
Étape 1 — Définir le contrat d'un outil
Tous vos outils partageront le mĂŞme contrat. Commencez par cette interface :
interface ToolInterface { public function getName(): string; // ex. "ping" public function getSchema(): array; // JSON Schema de l'entrée public function execute(array $args): array; // sortie structurée }
Écrivez maintenant votre premier outil, PingTool. Il déclare son nom, le schéma de ses arguments, et ce qu'il fait :
final class PingTool implements ToolInterface { public function getName(): string { return 'ping'; } public function getSchema(): array { return [ 'type' => 'object', 'properties' => ['message' => ['type' => 'string']], 'required' => ['message'], 'additionalProperties' => false, ]; } public function execute(array $args): array { return ['status' => 'ok', 'echo' => $args['message'] ?? '']; } }
Le additionalProperties => false refuse tout argument non prévu : prenez l'habitude dès maintenant.
Plus tard, en production, on n'instancie pas les outils à la main : on les enregistre comme services taggés (ex.
mcp.tool) et on les collecte dans un registre. Pour ce tutoriel, un simple tableau suffit.
Étape 2 — Écrire la boucle stdio (JSON-RPC 2.0)
Le transport stdio fonctionne par lignes : le serveur lit une requête JSON par ligne sur stdin, écrit sa réponse sur stdout. Retenez bien : stderr est réservé aux logs, jamais stdout.
Créez le point d'entrée (une commande console Symfony, ou un simple script bin/mcp-server) :
// bin/mcp-server $tools = ['ping' => new PingTool()]; while (($line = fgets(STDIN)) !== false) { $req = json_decode(trim($line), true); if (!is_array($req) || ($req['jsonrpc'] ?? null) !== '2.0') { fwrite(STDOUT, encodeError(null, -32600, 'Invalid Request') . "\n"); continue; } $id = $req['id'] ?? null; $result = match ($req['method'] ?? '') { 'initialize' => [ 'protocolVersion' => '2025-11-25', 'capabilities' => ['tools' => new stdClass()], 'serverInfo' => ['name' => 'mon-serveur-mcp', 'version' => '0.1.0'], ], 'tools/list' => ['tools' => array_map( fn (ToolInterface $t) => [ 'name' => $t->getName(), 'inputSchema' => $t->getSchema(), ], array_values($tools) )], 'tools/call' => callTool($tools, $req['params'] ?? []), default => null, }; if ($id === null) { // notification : pas de réponse attendue continue; } fwrite(STDOUT, json_encode(['jsonrpc' => '2.0', 'id' => $id, 'result' => $result]) . "\n"); } function callTool(array $tools, array $params): array { $name = $params['name'] ?? ''; $args = $params['arguments'] ?? []; if (!isset($tools[$name])) { return ['isError' => true, 'content' => [['type' => 'text', 'text' => "Outil inconnu : $name"]]]; } $out = $tools[$name]->execute($args); return ['content' => [['type' => 'text', 'text' => json_encode($out, JSON_UNESCAPED_UNICODE)]]]; } function encodeError(?int $id, int $code, string $msg): string { return json_encode(['jsonrpc' => '2.0', 'id' => $id, 'error' => ['code' => $code, 'message' => $msg]]); }
Deux points Ă comprendre avant de continuer :
- Un message sans
idest une notification : on ne répond pas. - La réponse d'un outil est encapsulée dans un tableau
content, avec un type (textici). C'est le format attendu par MCP.
Ceci est un squelette pédagogique. En production, on valide chaque entrée contre son
inputSchema, on passe par un registre d'outils, on gère le cycle de vie complet (dontnotifications/initialized) et on applique les contrôles de sécurité.
Étape 3 — Tester à la main
Pas besoin de client pour vérifier que ça marche : envoyez les messages directement au serveur. Chaque commande envoie une ligne JSON sur stdin.
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' \ | php bin/mcp-server printf '%s\n' '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' \ | php bin/mcp-server printf '%s\n' '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"ping","arguments":{"message":"bonjour"}}}' \ | php bin/mcp-server
Vous devez voir, dans l'ordre :
- la réponse d'
initialize(protocole + capacités), - le catalogue, contenant votre outil
ping, - l'écho de votre message.
Si la troisième commande vous renvoie bonjour, la boucle complète fonctionne. Bravo, vous avez un serveur MCP.
Étape 4 — Remplacer ping par un vrai outil
ping n'était qu'un banc d'essai. Le pas suivant : écrire un outil utile qui suit exactement le même contrat ToolInterface.
Un cas classique est un outil de recherche documentaire, par exemple search_docs, qui reçoit {query, k}, délègue à votre service de recherche interne, et renvoie les passages trouvés avec leur score et leur source. Vous ne changez rien à la boucle stdio : vous ajoutez simplement l'outil au tableau $tools.
C'est tout l'intérêt du contrat : la mécanique de transport reste stable, seuls les outils évoluent.
Ă€ retenir
stdout= MCP uniquement. Aucunecho,var_dumpou log parasite sur la sortie standard : ils casseraient le flux JSON-RPC. Les logs vont surstderr.- Schémas stricts.
additionalProperties: falseet sorties structurées : moins de surprises, des outils plus fiables. - La sécurité vient avec les vrais outils. Dès que vos outils touchent des données ou écrivent quelque chose, prévoyez un périmètre d'accès et, pour les écritures, une validation humaine dans la boucle.
Pour aller plus loin
- Brancher votre serveur sur un client MCP (assistant de bureau, éditeur compatible).
- Introduire un registre d'outils pour ne plus les instancier Ă la main.
- Valider automatiquement les arguments reçus contre l'
inputSchemade chaque outil.