Aller au contenu principal

Créer un serveur MCP avec Symfony.

Monter un serveur MCP sur un projet Symfony (mcp-bundle) : déclarer un outil #[McpTool], l'exposer en HTTP, et quelques pièges à déjouer.

MAJ 7 min de lecture
Sommaire · 6

Un serveur MCP, c'est une façon standardisée d'exposer des outils à un client comme Claude. Pas une API REST de plus : un endpoint qui décrit lui-même ses outils, leurs paramètres et leurs descriptions, et que l'agent appelle tout seul. Symfony a un bundle pour ça, symfony/mcp-bundle. On va monter un serveur MCP sur un projet Symfony existant, et surtout voir les morceaux que la doc ne montre pas : la config qui compte vraiment, comment déclarer un outil, et les pièges qu'on ne paie qu'une fois en prod.

Le bundle, en quatre lignes de config

Toute la personnalité du serveur tient dans un fichier de config :

PHP
// config/packages/mcp.php
$containerConfigurator->extension('mcp', [
    'app' => 'lecodeestdanslepre',
    'version' => '1.3.0',
    'client_transports' => ['stdio' => false, 'http' => true],
    'discovery' => ['scan_dirs' => ['src/Mcp']],
    'http' => ['path' => '/_mcp'],
]);

Deux choix se logent là. Le transport http plutôt que stdio. stdio, c'est un serveur local que le client lance lui-même : par utilisateur, zéro réseau, zéro auth à monter. Parfait pour un outil de dev, un cron, une CI, et un autre serveur du même projet tourne justement en stdio pour introspecter le conteneur de dev. Ici, on veut l'inverse : un serveur partagé, joignable à distance. Donc http, et tout l'appareillage de sécu qui suit en est le prix. Et scan_dirs, qu'on verra plus bas, parce que c'est le premier piège.

Un mot sur les versions, parce que ça date tout ce qui suit : c'est observé sur symfony/mcp-bundle 0.10 et mcp/sdk 0.6, deux briques encore en 0.x. Les comportements décrits, surtout les pièges plus bas, sont datés et finiront par bouger. Au passage, le 'version' => '1.3.0' du fichier de config ci-dessus, ce n'est pas la version du bundle : c'est celle du serveur qu'on expose.

Un outil, c'est une méthode annotée

Pas de classe à enregistrer à la main, pas de YAML. Une méthode publique, un attribut #[McpTool], le bundle la découvre au boot et l'expose en JSON-RPC :

PHP
#[McpTool(
    name: 'redirect-match-path',
    description: 'Résout la règle de redirection qui matcherait un chemin donné (exact d\'abord, puis la regex de plus haute priorité).',
)]
public function matchPath(string $path): string
{
    $rule = $this->redirectRuleRepository->findMatchingRule($path);
    if (!$rule instanceof RedirectRule) {
        return McpJson::encode(['error' => 'no_match', 'path' => $path]);
    }

    return McpJson::encode([
        'matched_path' => $path,
        'final_target' => $rule->applyTarget($path),
        'rule' => $this->mutationService->serialize($rule),
    ]);
}

La description n'est pas du commentaire : c'est elle que l'agent lit pour décider quand appeler l'outil. On l'écrit pour un lecteur qui n'a pas le code sous les yeux. Le typage des paramètres (string $path) devient le schéma d'entrée que le client valide. Une méthode bien typée, c'est un outil bien décrit. Pour l'entrée, du moins : côté sortie, le bundle ne décrit rien. À cette version, il ne génère pas d'outputSchema, et ce que l'outil retourne repart en simple bloc de texte (ici un JSON sérialisé à la main par McpJson, un helper maison, pas une API du bundle). Le client reçoit une chaîne, pas une structure typée.

L'exposer sans ouvrir la porte en grand

La route MCP est publique et exécute du code privilégié, donc on l'isole. Un sous-domaine dédié :

PHP
// config/routes/mcp.php
$routes->import('.', 'mcp')->host('acme.%app.domain%');

Un rate-limit consommé avant l'authentification (les scans se font jeter sans toucher au firewall), un firewall stateless sur ^/_mcp qui exige un rôle dédié, et c'est de la sécu Symfony standard. On ne s'y attarde pas : ce qui n'est pas explicitement exposé n'est pas joignable. Reste qu'on décrit là le portier, pas la clé. Comment le client présente son jeton (dans l'en-tête Authorization, validé avant que le rôle soit accordé), et surtout comment on remplace ce Bearer statique par un flux OAuth : ce sera pour un prochain billet.

Quand l'outil écrit, pas juste lit

Un outil qui lit, c'est confortable. Un outil qui crée ou modifie du contenu en prod, c'est un autre métier. Trois règles tiennent la surface d'écriture.

Le dry_run partout : chaque outil mutant accepte un drapeau qui valide sans persister. L'agent peut proposer un changement, le voir, et seulement ensuite l'appliquer.

Les mêmes validators que le CRUD admin tournent sur l'entité, donc l'agent ne peut pas écrire ce qu'un humain n'aurait pas le droit d'écrire. Et chaque mutation laisse une trace dans les logs applicatifs.

Dernière règle, la plus nette : pas de suppression. Retirer un contenu, c'est le repasser en brouillon, pas l'effacer. La seule exception assumée, ce sont les règles de redirection, et là le dry_run est activé par défaut, avec un avertissement quand la règle sert encore. Parce qu'un brouillon se restaure, mais une 301 vivante qu'on supprime, c'est du jus SEO qu'on ne récupère pas.

Quand ça ne veut pas

Le scan qui plante en silence. Le bundle scanne le code au boot pour trouver les #[McpTool]. S'il croise une classe dont la parente vit en require-dev (une story Foundry, par exemple), l'autoload échoue, le scan avale l'exception, et l'outil n'apparaît pas. Zéro log, zéro indice : tools/list répond simplement une liste vide. La parade tient dans la config, scan_dirs: ['src/Mcp'] restreint le scan au seul dossier qui contient des outils.

Les sessions jetées à chaque déploiement. Par défaut, l'état de session MCP vit dans var/cache/, que cache:warmup régénère à chaque deploy. Conséquence : le client doit refaire un handshake après chaque mise en ligne. On déplace les sessions hors du cache, dans un dossier monté sur un volume qui survit :

PHP
'http' => [
    'path' => '/_mcp',
    'session' => ['store' => 'file', 'directory' => '%kernel.project_dir%/var/mcp-sessions', 'ttl' => 3600],
],

Le middleware qui bloque tout derrière un reverse-proxy. Le SDK MCP embarque une protection anti-DNS-rebinding dont l'allow-list par défaut est verrouillée sur localhost. En local, tout marche sans y penser. En prod, sur un vrai sous-domaine, la même requête authentifiée renvoie un 403 Forbidden: Invalid Host header. L'indice qui oriente l'enquête : sans jeton on a un 401, le 403 n'apparaît qu'une fois authentifié, donc le blocage est après le firewall, pas avant. Et le bundle n'expose aucune clé de config pour surcharger cette allow-list. La parade : remplacer le contrôleur du bundle par un contrôleur maison qui rejoue la même pile de middlewares, mais avec l'allow-list câblée sur le host réel (acme.%app.domain%). Le filtrage reste actif, simplement aligné sur la prod.

Le mot de la fin

Un serveur MCP, ce n'est pas brancher un LLM sur son site. C'est l'inverse : exposer un endroit dont on décide à l'avance chaque outil, chaque paramètre, et chaque chose qu'on refuse d'exposer. Le client peut être Claude Code aujourd'hui, un cron ou un agent maison demain. Le serveur, lui, ne fait que ce qu'on lui a permis de faire. Reste la question intéressante : la prochaine fois qu'on ajoute un outil, est-ce qu'on lui donne le droit d'écrire ?

Une coquille, une erreur dans ce billet ? Signale-la-moi.

Activez uniquement ce que vous souhaitez. Vos choix sont conservés 6 mois.

Strictement nécessaires

Indispensables au fonctionnement du site (session, sécurité, préférence d'affichage). Aucune donnée n'est partagée à des tiers et aucun consentement n'est requis.

Toujours actif

Mesure d'audience

Statistiques via Google Analytics (GA4) : pages vues, source du trafic, navigateur et interactions clés. Dépose des cookies de mesure, activés seulement avec votre accord (Consent Mode). Sans publicité ciblée, sans Google Signals, sans partage commercial.

Contenus externes

Affiche les GIF animés hébergés par Giphy (CDN aux États-Unis). À l'affichage d'un GIF, votre adresse IP et votre navigateur sont transmis à Giphy. Sans votre accord, les GIF ne s'affichent pas.