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 :
// 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 :
#[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é :
// 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 :
'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.