Le blog

Transformer une API en boîte à outils pour agents : l’architecture de notre assistant de conférence

Publié le 7 septembre 2026

La première partie de ce retour d’expérience expliquait le choix d’architecture : construire un serveur MCP plutôt qu’un chatbot isolé, pour que le service soit accessible à n’importe quel agent IA, l’interface de chat devenant simplement son premier client. Cet article rentre dans le détail technique de ce service et du client qui s’y connecte. La partie 3 évoque les outils souverains qu’on a utilisés.

La décision d’architecture la plus structurante a été de s’appuyer sur l’écosystème existant plutôt que d’écrire du code de transport MCP à la main. Pas de dispatcher JSON-RPC, pas de sérialiseur de schéma, pas de routeur d’outils développés maison : API Platform expose maintenant les ressources d’API comme des outils MCP via un seul attribut. La couche State, qui alimente déjà le REST et le GraphQL, fait le travail de handler d’outils sans qu’on ait rien à changer. Le reste de la stack : FrankenPHP, Mercure, Symfony AI et Mistral s’articule autour de cette brique centrale. On détaille chaque composant dans les sections qui suivent.

L'architecture de notre assistant de conférence
L’architecture de notre assistant de conférence

La forme du système

L’interface de chat n’est qu’un client parmi d’autres. Claude Desktop, un assistant d’IDE ou un script maison, du moment qu’ils parlent MCP, ils se connectent au même serveur et accèdent aux mêmes 21 outils. Cette interopérabilité découle directement des choix d’architecture qu’on a faits au départ.

Un attribut transforme une ressource en outil

Le support MCP d’API Platform permet de déclarer des outils directement dans les métadonnées de la ressource. Voici la ressource Feedback, qui gère les votes et les commentaires, avec deux de ses outils :

#[ApiResource(
    operations: [],
    mcp: [
        'talk-feedback' => new McpTool(
            name: 'talk-feedback',
            description: 'Read the community feedback for an API Platform Conference 2026 talk: number of votes, average rating and the comments (with author). Public — no authentication needed.',
            input: FeedbackInput::class,
            processor: TalkFeedbackProcessor::class,
            structuredContent: true,
        ),
        'vote-talk' => new McpTool(
            name: 'vote-talk',
            description: 'Rate an API Platform Conference 2026 talk (1–5). Requires a GitHub access token (sent as a Bearer token); one vote per talk per user — voting again updates your rating. Returns the updated feedback.',
            input: VoteInput::class,
            processor: VoteTalkProcessor::class,
            structuredContent: true,
        ),
        'comment-talk' => new McpTool(
            name: 'comment-talk',
            description: 'Post a comment on an API Platform Conference 2026 talk. Requires a GitHub access token (sent as a Bearer token); the comment is attributed to your GitHub login. Returns the updated feedback.',
            input: CommentInput::class,
            processor: CommentTalkProcessor::class,
            structuredContent: true,
        ),
    ],
)]
final class Feedback {/* properties */}

Cette déclaration suffit à définir l’outil : pas de handler JSON-RPC à maintenir à la main, pas de schéma de validation, pas de table de routage. À partir de cette config, API Platform fait trois choses :

  • il génère le JSON Schema d’entrée depuis la classe input typée, pour que l’agent sache formuler des requêtes valides ;
  • il sérialise la ressource en contenu structuré des champs typés plutôt qu’un bloc de texte formaté ;
  • il route l’appel vers le processor pour les écritures, ou vers un provider pour les lectures.

Sur les douze ressources du projet (Talk, Speaker, Event, Agenda, Feedback…), ce modèle génère vingt-et-un outils. La description textuelle fait ici office de prompt : le modèle la lit à chaque décision pour juger si l’outil est pertinent et quels arguments lui passer.

La couche State remplit un double rôle

Les outils MCP réutilisent la couche State classique d’API Platform. Un outil de lecture s’appuie sur une implémentation de ProviderInterface, un outil d’écriture sur ProcessorInterface. Pas de duplication, pas de code séparé rien que pour les agents.

Voici la structure simplifiée de la classe VoteTalkProcessor associée à l’outil vote-talk :

final readonly class VoteTalkProcessor implements ProcessorInterface
{
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): Feedback
{
    /* Security & validation */

    $talk = $this->conference->findTalk($data->slug)
        ?? throw new \RuntimeException(\sprintf('Unknown talk "%s".', $data->slug));

    $existing = $this->em->getRepository(Vote::class)->findOneBy([
        'talkSlug' => $talk->slug,
        'authorId' => $user->githubId,
    ]);

    /* Upsert logic: adding or replacing existing vote for user */

    return $this->feedback->summary($talk->slug, $talk->title, $message);
}
}

Cette même classe pourrait tout aussi bien traiter une requête REST POST équivalente. La logique métier (vérification de l’identité, unicité du vote par utilisateur·ice, mise à jour en base et retour du feedback) reste centralisée à un seul endroi,  MCP n’est qu’un protocole de transport en surface. Pour ajouter une capacité, il faut écrire un provider ou un processor et de le déclarer avec McpTool : le transport et la sérialisation, eux, sont pris en charge automatiquement.

Des entrées typées, validées une seule fois

Chaque outil qui modifie des données déclare une classe d’entrée typée, comme VoteInput ou CommentInput. API Platform s’en sert pour générer le schéma d’entrée de l’outil : si l’agent envoie un type incorrect ou oublie un paramètre obligatoire, la validation bloque la requête avant même d’arriver à notre code. Les règles qu’un schéma ne peut pas exprimer (une note qui doit être entre 1 et 5, une longueur de texte maximale) sont vérifiées dans le processor. La structure est validée à la frontière, le sens métier dans le handler.

FrankenPHP : un runtime persistant

Le serveur tourne sur FrankenPHP en mode worker, le runtime PHP basé sur Caddy. Contrairement au PHP-FPM classique, qui réinstancie le kernel Symfony à chaque requête, le mode worker ne l’initialise qu’une fois et traite ensuite plusieurs milliers de requêtes avec. Ça supprime la latence de démarrage, un vrai sujet quand l’agent enchaîne plusieurs appels d’outils dans un même tour de conversation.

Le fichier de configuration Caddyfile gère l’orchestration du mode worker, du hub Mercure et du service de fichiers statiques :

{$SERVER_NAME:localhost} {
    root /app/public
    mercure { /* Configuration des jetons JWT et accès anonymes */ }
    php_server {
        worker {
            file ./public/index.php
        }
    }
}

Le mode worker a une conséquence directe : l’état des services persiste entre les requêtes. Un service qui garde une valeur en cache sans la nettoyer la refilera à la requête suivante. Symfony fournit l’interface ResetInterface pour ça. Exemple avec le nonce de sécurité (Content-Security-Policy), réinitialisé à chaque requête :

final class CspNonceProvider implements ResetInterface
{
    private ?string $nonce = null;

    public function getNonce(): string
    {
        return $this->nonce ??= bin2hex(random_bytes(16));
    }

    public function reset(): void
    {
        $this->nonce = null;
    }
}

Sans cet appel à reset(), le nonce du premier visiteur se retrouverait partagé avec les requêtes suivantes : un problème de sécurité propre au cycle de vie persistant du mode worker.

Flux de réponses avec Mercure et Live Components

L’interface de chat repose sur un composant Symfony UX Live Component. Pour rester réactif, le système affiche le message de l’utilisateur·ice immédiatement, puis restitue la réponse de l’assistant au fil de sa génération, token par token. Le traitement se fait en deux étapes.

La première, submit(), enregistre le message, affiche un indicateur d’attente et génère un canal (topic) Mercure à usage unique puisque l’agent n’est pas encore sollicité. La seconde, reply(), se déclenche automatiquement dès que le client est connecté au canal : elle appelle l’agent en streaming, publie chaque token sur le canal au fur et à mesure, puis enregistre le texte Markdown complet dans l’état du composant Live une fois terminé. C’est ce découpage qui garde l’interface réactive.

Un détail technique à gérer dans la boucle de streaming : les caractères multi-octets.

// Prepare context window
$messageBag = $this->buildMessageBag();
$execution = $this->conference->call($messageBag, ['stream' => true]);

foreach ($execution->asTextStream() as $delta) {
    $text = $delta->getText();

    if ('' === $text) {
        continue;
    }

    $answer .= $text;
// Publishes longest valid-UTF-8 prefix & leaves the incomplete tail for the next call
    $this->bufferAndPublish($topic, $byteBuffer, $text);
}
// Flush whatever was buffered
$this->flush($topic, $byteBuffer);

Les tokens générés par un modèle de langage ne s’alignent pas forcément sur les limites UTF-8 : un caractère codé sur plusieurs octets peut se retrouver coupé en deux entre deux fragments. Pour éviter que json_encode plante sur un flux binaire partiel, les fragments incomplets sont mis de côté dans un tampon, et seul le préfixe UTF-8 valide est publié tout de suite. Mercure s’occupe de diffuser ces fragments vers le navigateur via Server-Sent Events. Et si un fragment se perd en route, le message complet est de toute façon réaffiché depuis l’état du Live Component à la fin de la requête.

Le client : Symfony AI et Mistral

L’interface graphique s’appuie sur un agent Symfony AI. Pour chacun des vingt-et-un outils MCP déclarés côté serveur, le client implémente une classe passerelle dédiée avec l’attribut #[AsTool]. On a préféré ça à la découverte dynamique à l’exécution : l’ensemble des outils reste figé et versionné dans le code du client, ce qui rend le comportement de l’agent prévisible.

La configuration de l’agent adopte une structure déclarative :

ai:
  platform:
    mistral:
      api_key: '%env(MISTRAL_API_KEY)%'
  agent:
    conference:
      platform: 'ai.platform.mistral'
      model:
        name: '%env(AI_MODEL)%'
        options:
          temperature: '%env(AI_TEMPERATURE)%'
      prompt:
        text: |
          You are the assistant for the API Platform Conference 2026
          (17–18 September 2026, EuraTechnologies, Lille — organized by Les-Tilleuls.coop).
          You help attendees explore the agenda, talks, speakers, schedule, partners and practical information.
          Rules:

Symfony AI abstrait le fournisseur d’inférence derrière l’interface AgentInterface. Changer de modèle ou de fournisseur devient une simple modification de configuration, sans toucher à la logique de l’agent.

Sécurisation de l’environnement de production

Laisser un agent autonome écrire en base de données via un protocole ouvert, ça ne se fait pas sans plusieurs couches de sécurité.

  • Authentification sur les écritures : la lecture reste anonyme, mais chaque modification exige un jeton d’accès GitHub, validé auprès de l’API officielle de GitHub à chaque requête. L’identité est vérifiée strictement côté serveur pour empêcher toute falsification.
  • Rate limiting : les outils d’écriture utilisent un token bucket par utilisateur·ice, basé sur l’identifiant numérique stable de GitHub. Ça limite les votes ou commentaires abusifs sans avoir besoin de stocker les adresses IP des visiteurs.
  • Données venant des utilisateur·ices : l’outil talk-feedback renvoie des textes saisis par le public, qui finissent injectés dans le contexte du modèle. Pour limiter le risque d’injection de prompt indirecte, ces données sont explicitement marquées comme du contenu non vérifié, et le prompt système interdit à l’agent de les interpréter comme des instructions.
  • Durcissement côté client : un listener centralisé applique des en-têtes de sécurité sur toutes les routes : Content-Security-Policy à base de nonce, X-Content-Type-Options, Referrer-Policy, protection contre le clickjacking.

L’infrastructure de déploiement

FrankenPHP assemble l’application en un seul exécutable, avec le serveur web, le runtime PHP et le hub Mercure dans le même processus. L’image Docker qui en résulte est déployée sur Clever Cloud. Le temps réel via Mercure tourne sans infrastructure additionnelle, ce qui réduit d’autant la surface à maintenir en prod.

Synthèse des technologies utilisées

  • API Platform : gestion des ressources, de la couche State et exposition native des outils MCP.
  • Symfony 8.1 : socle applicatif global.
  • FrankenPHP : runtime d’exécution en mode worker.
  • Mercure : protocole de transmission des flux temps réel vers le client.
  • PostgreSQL : persistance des votes, des commentaires et des agendas.
  • Symfony AI : couche d’abstraction pour l’agent et la gestion des outils d’inférence.
  • Mistral : infrastructure d’inférence.

En s’appuyant sur l’écosystème Symfony et API Platform, on démontre qu’il est possible de bâtir un serveur MCP robuste, performant et sécurisé sans surcouche complexe. Du streaming en temps réel via Mercure aux optimisations du mode worker de FrankenPHP, l’architecture privilégie la réutilisabilité métier plutôt que le couplage à une simple interface web. En traitant le serveur comme le produit central et le chat comme un client parmi d’autres, cette approche offre un socle pérenne pour n’importe quel agent IA. La troisième partie de ce retour d’expérience abordera le volet de la souveraineté.

Julien Lary
Julien Lary Expert developer

Mots-clés Agent IA, API Platform Con, MCP, Mistral