Un serveur MCP avec Symfony AI, pour que votre IA crée des bacs à sable PHP
Quand on demande à une IA « montre-moi comment valider un formulaire avec une FormRequest », elle répond par trois blocs de code à recopier dans un projet qu'on n'a pas forcément sous la main. On lit, on hoche la tête, et on ne teste jamais.
Le bac à sable d'apprendre.forelse.fr fait déjà tourner un projet Symfony, Laravel ou PHP dans le navigateur, et le partage par un simple lien. Il manquait une chose : que l'IA puisse fabriquer ce lien elle-même. C'est ce que permet le Model Context Protocol (MCP), le protocole qui donne des outils aux assistants. J'ai donc ajouté un serveur MCP au site, en Symfony, avec le bundle de Symfony AI.
Résultat : on demande à Claude « crée-moi un bac à sable Symfony avec un contrôleur /hello/{name} qui répond en JSON », et il répond avec un lien. Un clic, « Exécuter », et le code tourne.
Et le cas qui m'intéresse le plus : un bug dans votre projet. Depuis Claude Code ou Cursor, l'IA a votre code sous les yeux. Elle isole ce qui pose problème, le contrôleur, le service et la ligne de services.yaml en cause, et en fait un bac à sable qui reproduit le bug. Le lien part dans une issue ou sur Slack, et la personne qui vous aide voit le problème tourner au lieu de le deviner.
Ce que fait le serveur, et ce qu'il ne fait pas
Le lien de partage du bac à sable porte déjà tout le code, après le # : les fichiers modifiés, en JSON, compressés avec deflate et encodés en base64url. J'ai décrit ce format dans Partager un projet Symfony dans une URL. Le site propose aussi des liens courts, gardés sur le serveur quand on coche la case au partage, mais le serveur MCP fabrique toujours le lien long : il ne stocke rien.
Une IA ne sait pas compresser du deflate de tête. Le serveur MCP fait donc ce calcul à sa place, et rien de plus :
get_starterdonne le projet de départ : ses fichiers, les chemins modifiables, la configuration (sur demande, celle de Laravel pèse 45 Ko) et les paquets Composer qu'on peut ajouter (pour Symfony, chacun arrive avec sa recette Flex) ;create_sandboxreçoit les fichiers que l'IA a écrits et renvoie le lien ;read_sandboxfait l'inverse : on donne un lien à l'IA, elle lit le code pour l'expliquer ou le corriger.
Rien ne s'exécute sur le serveur, et rien n'est enregistré. Le code tourne ensuite dans le navigateur de la personne, en WebAssembly, et seulement après son clic, comme pour tout lien partagé.
symfony/mcp-bundle : un outil, c'est un attribut
Symfony AI fournit symfony/mcp-bundle, construit sur le SDK PHP officiel de MCP (mcp/sdk), développé avec la PHP Foundation. L'installation passe par une recette Flex :
composer require symfony/mcp-bundle
La recette ajoute le fichier de configuration et l'import des routes. J'y déclare un serveur, l'espace de noms où chercher ses outils, et l'adresse : par défaut /mcp/<nom du serveur>, que je fixe à /mcp :
mcp: servers: playground: name: 'php-playground' version: '1.0.0' instructions: | Creates links to an online PHP playground (Symfony 8, Laravel 13, or plain PHP 8.5)... Call get_starter for the starting project, then create_sandbox with only the files you change, and give the user the link. registry: tools: ['App\Mcp\'] http: path: /mcp
Un outil, ensuite, c'est une méthode de service avec l'attribut #[McpTool]. Le SDK génère le schéma JSON des paramètres à partir de la signature PHP :
#[McpTool(name: 'create_sandbox', annotations: new ToolAnnotations(readOnlyHint: true, idempotentHint: true))] public function create( #[Schema(description: 'The playground: "symfony", "laravel" or "php" (no framework).')] ScratchFramework $framework, #[Schema(definition: [ 'type' => 'object', 'description' => 'The files to change or add, by path, with their full content.', 'additionalProperties' => ['type' => 'string'], ])] array $files, #[Schema(description: 'Language of the page: "fr" for a French-speaking user, else "en".', enum: ['en', 'fr'])] string $language = 'en', // $packages et le RequestContext omis ): array { // ... return ['url' => $this->url($framework, $language).'#'.$this->links->encode($changed)]; }
Deux détails m'ont fait gagner du temps :
- Un enum PHP devient un
enumJSON.ScratchFrameworkest un enum adossé à des chaînes (symfony,laravel,php) : le SDK en tire la liste des valeurs pour le schéma, et convertit l'argument reçu avant l'appel. - Le commentaire PHPDoc devient la description. C'est lui que le modèle lit pour décider quand appeler l'outil. Je l'ai donc écrit pour le modèle, en anglais, en disant quand l'utiliser et dans quel ordre.
Un array PHP, en revanche, ne dit pas s'il s'agit d'une liste ou d'un objet. Pour files, une table « chemin vers contenu », il faut donc donner le schéma complet avec #[Schema(definition: ...)].
Des erreurs écrites pour le modèle
Le bac à sable n'accepte pas n'importe quel fichier. Le code, les vues et la configuration se modifient (src/*/*.php, templates/*.html.twig, config/packages/*.yaml…), mais pas ce qui dépend des paquets : composer.json, config/bundles.php et vendor/ ne bougent qu'à travers la liste des paquets proposés. Si l'IA envoie directement config/bundles.php, la page ignore le fichier sans rien dire.
Le serveur refuse donc l'appel, avec une ToolCallException. Le SDK en fait un résultat d'erreur que le modèle lit, et non une erreur de protocole :
if ([] !== $refused) { throw new ToolCallException(sprintf( 'These paths cannot be edited in the %s playground: %s. Editable paths: %s ("*" does not cross a "/").', $framework->label(), implode(', ', $refused), implode(', ', $exercise->editable), )); }
Le message dit ce qui ne va pas et ce qui est permis. Le modèle corrige son appel au tour suivant, sans que l'utilisateur voie quoi que ce soit. Même chose pour les paquets Composer : un paquet inconnu reçoit la liste du catalogue, et un paquet ajouté à Laravel reçoit une réponse claire, ce bac à sable arrivant avec ses propres paquets.
Le serveur enlève aussi du lien les fichiers identiques au projet de départ. Les modèles aiment renvoyer tout ce qu'on leur a montré, et un fichier inchangé ne ferait qu'allonger l'URL.
Un serveur public, sans compte : les précautions
Le serveur n'a ni compte ni donnée privée, mais il reste un point d'entrée ouvert. Quatre réglages :
- Une limite de requêtes par adresse IP, avec le RateLimiter de Symfony : 300 requêtes par heure, puis une réponse 429 avec
Retry-After. Une conversation fait quelques appels, pas des milliers. - Les mêmes plafonds que la page : 100 fichiers et 1 Mo de code au plus. Un lien que la page refuserait n'est pas fabriqué, et
read_sandboxrelit les liens avec la même méfiance (taille avant et après décompression, chemins qui remontent avec..écartés). - Un pare-feu sans sécurité sur
/mcp, pour qu'aucune session de la plateforme ne s'y mêle. - La protection contre le DNS rebinding désactivée (
allowed_hosts: false). Par défaut, le SDK n'accepte quelocalhost,127.0.0.1et[::1], ce qui convient à un serveur MCP local. Pour un serveur public, on peut lister ses domaines. J'ai choisi de la désactiver parce que le moteur du site tourne sur plusieurs instances, chacune avec son domaine, et que le serveur ne protège aucune donnée.
Un piège pendant les tests : en PHP, un objet JSON vide {} et une liste vide [] deviennent le même tableau vide. Le SDK corrige ce cas pour arguments, mais pas pour un objet imbriqué : un "files": {} était vu comme une liste, et le validateur de schéma refusait l'appel avant même d'atteindre l'outil. Un vrai assistant envoie toujours au moins un fichier, mais le test, lui, envoyait {}. Dans l'autre sens, le serveur force (object) $files avant d'encoder le lien, pour que la page reçoive toujours un objet.
Mesurer ce qu'une IA fait du serveur
La mesure d'audience du site, Umami, ne voit que les navigateurs. Un assistant IA appelle /mcp depuis un serveur : Umami ne le voit pas. Le serveur MCP envoie alors lui-même un événement à Umami à chaque appel d'outil (mcp-depart, mcp-lien-cree, mcp-lien-lu), avec le framework, la langue, le nombre de fichiers et le nom du client MCP annoncé à la connexion (claude-ai, cursor-vscode…). Le code, lui, n'est jamais envoyé. L'envoi attend une seconde au plus, et un Umami injoignable ne fait jamais échouer l'outil.
Les liens créés portent aussi utm_source=mcp. Je saurai ainsi combien de liens les assistants fabriquent, et combien de personnes les ouvrent vraiment.
Tester un serveur MCP comme un client
Le test fonctionnel se comporte comme un assistant : une poignée de main JSON-RPC (initialize), la liste des outils, puis un appel. Avec le KernelBrowser de Symfony, c'est une suite de requêtes POST :
$this->client->request('POST', '/mcp', server: [ 'CONTENT_TYPE' => 'application/json', 'HTTP_ACCEPT' => 'application/json, text/event-stream', 'HTTP_MCP_SESSION_ID' => $this->session, ], content: json_encode([ 'jsonrpc' => '2.0', 'id' => 2, 'method' => 'tools/call', 'params' => ['name' => 'create_sandbox', 'arguments' => [ 'framework' => 'php', 'files' => ['public/index.php' => "<?php echo 'Bonjour';"], ]], ]));
La réponse contient un structuredContent avec l'URL. Le test décode ensuite le fragment pour vérifier qu'il porte bien le fichier.
Reste à garantir que PHP et la page parlent le même format. Deux tests s'en chargent, un dans chaque sens : côté PHP, ScratchLinkTest relit un fragment fabriqué par fflate, la bibliothèque de la page ; côté JavaScript, un test relit un fragment produit par gzdeflate. Les deux produisent le même deflate brut, et si l'une des implémentations change, un des deux tests casse.
L'essayer
Le serveur est à l'adresse https://apprendre.forelse.fr/mcp, sans compte. Dans Claude Code :
claude mcp add --transport http php-playground https://apprendre.forelse.fr/mcp
Pour Claude, ChatGPT, Cursor ou VS Code, la page Le bac à sable depuis votre assistant IA donne la marche à suivre. Demandez-lui ensuite un bac à sable, demandez-lui de reproduire le bug sur lequel vous butez, ou donnez-lui un lien reçu d'un collègue et demandez-lui pourquoi la route renvoie une 404.
Le bundle et le SDK sont encore expérimentaux : ils ne sont pas couverts par la promesse de compatibilité de Symfony, et il faudra suivre leurs versions. Mais pour exposer une poignée d'outils, quelques attributs sur un service suffisent, avec le pare-feu, le RateLimiter et les tests fonctionnels qu'on connaît déjà.