03-comment-je-travaille/tutos/ia/serveur-mcp-symfony.md

Tuto — Un serveur MCP minimal en Symfony

Public visé : développeur Symfony qui veut exposer des outils à un assistant IA via MCP. Objectif : construire un serveur MCP minimal (transport stdio, JSON-RPC 2.0) exposant un outil, conforme à la spec specs/ia-mcp.md.

Concepts : voir la fiche MCP : connecter l'IA Ă  vos outils. Pour l'installation sur VPS, voir MCP sur VPS.


Ce qu'on construit

Un serveur stdio qui répond à trois messages MCP :

  • initialize (poignĂ©e de main + capacitĂ©s),
  • tools/list (catalogue d'outils),
  • tools/call (exĂ©cution d'un outil).

On exposera un seul outil pour commencer : ping (puis on branchera search_docs sur le cœur RAG).


1. Le contrat d'un outil

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
}
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'] ?? ''];
    }
}

Les outils sont enregistrés comme services taggés mcp.tool et collectés dans un ToolRegistry (cf. spec §8). Ici, on garde un tableau pour l'exemple.


2. La boucle stdio (JSON-RPC 2.0)

Le transport stdio lit une requête JSON par ligne sur stdin et écrit la réponse sur stdout ; stderr est réservé aux logs.

// bin/mcp-server (commande console ou script)
$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' => 'tlr-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
        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]]);
}

Squelette pédagogique : en production, on s'appuie sur le ToolRegistry, on valide l'inputSchema, on applique scopes/ADN/audit (spec §6, §8) et on gère le cycle de vie complet (notifications/initialized).


3. Tester Ă  la main

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

On doit voir, ligne par ligne : la réponse d'initialize, le catalogue (ping), puis l'écho.


4. Brancher search_docs sur le cœur

Une fois la mécanique en place, on remplace PingTool par un SearchDocsTool qui délègue au RetrievalService du cœur RAG (specs/ia-coeur.md) : l'outil reçoit {query, k}, renvoie les passages avec score et source. C'est l'ancre V1 de la spec MCP.


Bonnes pratiques retenues

  • stdout = MCP uniquement : aucun echo/var_dump parasite ; logs sur stderr.
  • SchĂ©mas stricts (additionalProperties: false) et sorties structurĂ©es.
  • SĂ©curitĂ© d'abord : scopes, pĂ©rimètre projet, humain dans la boucle pour les Ă©critures (V2).

Pour aller plus loin

Assistant documentaire

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

Loading…
Loading the web debug toolbar…
Attempt #