tutorial/serveur-mcp-symfony.md

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 id est une notification : on ne rĂ©pond pas.
  • La rĂ©ponse d'un outil est encapsulĂ©e dans un tableau content, avec un type (text ici). 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 (dont notifications/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 :

  1. la réponse d'initialize (protocole + capacités),
  2. le catalogue, contenant votre outil ping,
  3. 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. Aucun echo, var_dump ou log parasite sur la sortie standard : ils casseraient le flux JSON-RPC. Les logs vont sur stderr.
  • SchĂ©mas stricts. additionalProperties: false et 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'inputSchema de chaque outil.

Assistant documentaire

Posez une question sur la documentation. Les réponses citent leurs sources — un clic ouvre le document à gauche.