Dix-sept petits outils MCP au lieu d’un agent géant : un système d’écriture par IA
Comment un système de recherche et d’écriture divise l’ingestion de vidéos, la recherche, la rédaction, la critique et la validation du code en outils MCP délimités, avec des artefacts et un suivi.
Sources: Why I Built MaxyAI as 17 Small MCP Tools Instead of One Giant Agent
La partie difficile d’un agent d’écriture basé sur l’IA n’est pas de faire en sorte qu’un modèle de langage écrive. Il s’agit plutôt de déterminer pour quoi le modèle ne doit pas être tenu pour responsable. Les premières tentatives de création de tels agents aboutissent généralement à une seule architecture : on donne un sujet à un modèle unique, on y ajoute un outil de recherche, et on lui demande de rechercher, raisonner, écrire, valider et se remettre des erreurs en une seule séquence continue. Les prompts s’allongent, les traces deviennent difficiles à déboguer, et les pannes déterministes se mêlent aux décisions sémantiques. MaxyAI, un système de recherche et d’écriture par l’IA, part d’une autre idée : faire en sorte que chaque capacité disposant d’entrées distinctes, de modes de panne, de règles de tentative répétée ou d’une sortie réutilisable devienne son propre outil délimité derrière un seul serveur MCP.
Les données d’entrée ne sont rarement organisées de manière ordonnée. Il peut s’agir d’une conférence sur YouTube, d’un répertoire GitHub, d’une page de documentation ainsi que de quelques liens de référence. MaxyAI intègre tout cela, élargit les recherches grâce à des recherches sur le Web, stocke le corpus qui s’agrandit sur disque et le structure en un plan. Il rédige ensuite section par section, envoie le brouillon à un outil d’évaluation numérique, extrait chaque bloc de code Python, valide et exécute ce code, corrige les erreurs de syntaxe, et exporte un article HTML autonome.
Il n’y a pas de prompt complexe derrière tout cela. Le système est un serveur FastMCP disposant de 17 outils enregistrés, dont 12 sont destinés à la recherche. Ces outils sont conçus pour être petits. L’un transcrit des vidéos, un autre comprend les répertoires GitHub, un autre effectue des recherches sur le Web, un autre crée des plans, un autre évalue les articles et un autre valide le code. Le modèle décide quel outil utiliser, et le code Python associé à chaque outil effectue le travail de manière déterministe.
Cette séparation constitue l’aspect le plus utile de cette conception. Le modèle peut être modifié sans toucher au processus d’ingestion sur YouTube. Le robot de parcours peut être remplacé sans perturber l’outil d’écriture. La phase de vérification du code peut être déboguée sans devoir relancer toute la recherche. De plus, comme chaque étape importante laisse un artefact ou une trace, un exécution autonome prolongée peut être examinée par la suite, au lieu d’être considérée comme un seul et unique transcript de conversation.
Le chiffre 17 n’a jamais été une cible. Il provient d’une règle simple : une capacité dispose de son propre outil ou gestionnaire dédié lorsqu’elle communique avec un système externe différent, génère un artefact réutilisable, nécessite un comportement de tentative différente, ou bénéficie d’une validation déterministe. MCP fournit alors à tous ces spécialistes une interface commune.
Pourquoi pas un seul outil géant ? Parce que le système résout plusieurs problèmes d’ingénierie distincts plutôt qu’un seul problème de raisonnement. Ces tâches (ingestion de vidéos, analyse de dépôts, découverte sur le web, gestion de l’état, rédaction, critique, exécution de code, réparation, publication) ont chacune leurs propres entrées, coûts, modes de défaillance et besoins de tentative de réexécution. Un outil unique raccourcirait les instructions, mais rendrait le système plus difficile à observer et à étendre.
Rien de tout cela ne prouve que chaque agent ait besoin de 17 outils. Il s’agit d’une étude de cas sur la décomposition : où le jugement sémantique relève d’un modèle, où le code ordinaire doit rester en charge, comment les artefacts peuvent remplacer un état conversationnel surchargé, et pourquoi le code généré doit passer des vérifications déterministes avant d’être vu par un lecteur.
Du début à la fin
Prenez le sujet des appels de fonctions des LLM et des sorties structurées, avec un ensemble initial de directives tel qu’une conférence sur YouTube, un répertoire GitHub et quelques URLs de documentation.
Phase 1 ne produit aucune prose. Son rôle est de collecter des éléments de preuve. Le serveur MCP sélectionne les URLs, transmet la vidéo à l’API multimodale de Gemini, convertit les répertoires en résumés adaptés aux modèles à l’aide de gitingest, parcourt les pages avec Firecrawl, comble les lacunes en effectuant des recherches via Tavily, et suit l’inventaire des sources dans les fichiers d’état situés à l’intérieur de .nova/. Le résultat est research.md, un important document de recherche que n’importe qui peut ouvrir et lire, à la place d’une mémoire de conversation cachée.
Phase 2 prend cet artefact et planifie sa publication. generate_article_outline attribue les différentes sections, les objectifs de nombre de mots et les objectifs de code, puis draft_article_section écrit une section délimitée par appel. Ensuite, critic_evaluate_article évalue le projet complet en ce qui concerne la profondeur technique, la qualité du code, la structure et la fidélité des citations, et refine_and_save_article applique les modifications requises avant d’écrire article.md.
Phase 3 traite le Markdown comme un texte qui doit survivre à son exécution et non simplement paraître convaincant. Les blocs Python sont extraits et analysés à l’aide de ast.parse(). Les extraits valides sont enregistrés sous .nova/sandbox/ et exécutés avec un délai de timeout de cinq secondes. En cas d’erreur de syntaxe, l’erreur ainsi que l’extrait sont envoyés à fix_code_snippets, et le résultat est soumis à une nouvelle validation. Une fois le code validé, export_publication_bundle génère le document HTML autonome.
Le suivi Opik couvre toute la durée d’exécution. Chaque suivi de modèle et d’outil expose la latence, les métadonnées du fournisseur, la hiérarchie des outils ainsi que les résultats intermédiaires. Vue sous forme de diagramme, l’architecture se compose de trois phases (recherche, rédaction et critique, contrôle qualité du code et publication) qui partagent des mécanismes de basculement en cas de panne de modèle et des fonctionnalités d’observabilité.
Le serveur MCP en tant que colonne vertébrale
Le premier choix de conception a eu lieu au niveau du protocole : exposer les capacités via le Model Context Protocol et ne pas intégrer chaque fonction directement dans une boucle d’application unique.
MCP fournit à un client un vocabulaire standard pour découvrir et appeler des outils. FastMCP rend cela simple en Python, car une fonction ordinaire typée devient un outil MCP grâce à un décorateur, et sa signature fait partie du contrat de l’outil. L’implémentation peut rester purement en Python même lorsque le client est un agent.
Dans le projet, mcp_server/src/routers/tools.py est le point d’entrée vers la couche des outils. Une registration simplifiée se présente comme ceci :
import opik
from fastmcp import FastMCP
from ..tools import run_web_research_tool
def register_mcp_tools(mcp: FastMCP) -> None:
@mcp.tool(name="run_web_research")
@opik.track(type="tool", tags=["research", "tavily"])
async def run_web_research(
queries: list[str],
research_directory: str,
) -> dict:
return await run_web_research_tool(
queries=queries,
research_directory=research_directory,
)
Le décorateur va au-delà de la simple embellissement de la syntaxe. Le client voit un outil nommé avec des arguments typés, tandis que l’implémentation reste au niveau de l’outil et du gestionnaire. Cette séparation devient importante lorsque le serveur dépasse un petit nombre de fonctionnalités.
Un seul outil do_everything(topic) a été délibérément évité, car il réintroduirait le couplage que la conception visait à éliminer. Des outils plus petits permettent au modèle de composer des comportements : extraire des sources, vérifier ce qui manque, rechercher à nouveau, esquisser, rédiger un brouillon, critiquer, corriger. Ils rendent également les traces beaucoup plus faciles à lire, car chaque étape correspond à une action significative.
Ainsi, le serveur composé de 17 outils n’est pas 17 commandes distinctes ; il s’agit d’un ensemble de capacités délimitées. La recherche occupe la part la plus importante, avec 12 outils, car c’est lors de l’acquisition des sources que le flux de travail nécessite le plus de variété.
Recherche : différentes sources, différents outils
La recherche a exigé le plus d’efforts en matière de conception, car « rechercher sur le Web » cache plusieurs opérations. Une vidéo, un dépôt GitHub et un site de documentation riche en JavaScript soulèvent trois problèmes de données différents, et le fait de les traiter avec un seul outil de scraping fournit au rédacteur un contexte insuffisant dès le départ.
Gemini pour YouTube
Résoudre les problèmes liés aux vidéos longues localement est coûteux : il faut télécharger l’audio, effectuer une reconnaissance vocale, gérer la mémoire GPU, puis résumer le texte transcrit. Le module youtube_handler.py emprunte une approche plus simple. Il transmet directement l’URL publique de YouTube à Gemini en tant qu’entrée multimodale et demande un format Markdown structuré avec des timestamps.
Le choix de Gemini ne vient pas du fait qu’il s’agisse du meilleur modèle pour l’ensemble du projet. C’est plutôt parce que cette capacité convient à la tâche : l’ingestion multimodale native permet au serveur de gérer des vidéos sans avoir besoin d’une pile Whisper locale ni d’une GPU sur la machine cliente.
Les erreurs du serveur fournisseur sont gérées par le même module grâce à des tentatives répétées de manière exponentielle, ce qui permet de contenir les pannes temporaires du modèle au sein de l’outil, de sorte que l’agent externe n’a pas besoin de comprendre les détails de réseau.
@retry(
retry=retry_if_exception_type(errors.ServerError),
wait=wait_exponential(multiplier=1, min=5, max=60),
stop=stop_after_attempt(3),
)
async def transcribe_youtube(url: str, output_path: Path, timestamp: int = 30):
client = track_genai_client(
genai.Client(api_key=settings.google_api_key.get_secret_value())
)
parts = [
types.Part(file_data=types.FileData(file_uri=url)),
types.Part(text=PROMPT_YOUTUBE_TRANSCRIPTION.format(timestamp=timestamp)),
]
response = await client.aio.models.generate_content(
model=settings.youtube_transcription_model,
contents=parts,
)
output_path.write_text(response.text, encoding="utf-8")
Dans une expérience rapportée, un exposé de 39 minutes a été transformé en notes structurées en 17,8 secondes sans utilisation de mémoire GPU du côté client. Il s’agit d’une expérience observée et non d’une référence universelle, car la charge du fournisseur et les conditions réseau peuvent la modifier. La trace enregistrée montrait l’identifiant du modèle gemini-3.7-flash ainsi que l’artefact d’état persistant .nova/guidelines_filenames.json.
gitingest pour les répertoires
Un répertoire n’est pas une page web. Le modèle a besoin de la structure des fichiers et du code source réel, et non du cadre de navigation de GitHub ; c’est pourquoi le gestionnaire de GitHub utilise gitingest pour générer un résumé adapté aux modèles de langage large de ce répertoire.
La validation de l’AST est une question distincte. gitingest se contente d’ingérer des données, fournissant au modèle de recherche une carte des répertoires ainsi que le contenu des fichiers, tandis que le parseur d’AST de Python intervient bien plus tard, lors du processus de publication qui vérifie le code généré. Mélanger les deux compliquerait l’architecture.
Les garder séparés facilite également l’extension. Un outil d’ingestion basé sur GitLab ou des dossiers locaux n’aurait qu’à générer le même artefact de recherche, car l’outil d’écriture est indifférent à l’origine du code.
Tavily et Firecrawl ensemble
Ces deux outils résolvent des aspects différents de la recherche sur le web. Tavily aide à la découverte, en transformant la question « Qu’est-ce que je n’ai pas encore trouvé ? » en une série de requêtes ciblées. Firecrawl intervient une fois que le serveur a sélectionné une page spécifique et souhaite en extraire proprement le contenu.
Le projet exploite cette différence pour rendre la recherche itérative. L’ingestion des références initiales n’est pas la fin : l’agent examine le corpus, rédige de nouvelles requêtes pour ce qui manque encore, les exécute, ajoute les résultats obtenus, et peut itérer jusqu’à trois fois.
La conception est délibérément asymétrique : le LLM décide de ce qui manque, tandis que Tavily et Firecrawl s’occupent de la récupération des informations. Ce même schéma se répète à chaque étape, le modèle étant utilisé là où le jugement sémantique est utile, et du code ou des services ordinaires là où un travail déterministe est préférable. Les activités de Firecrawl lors d’une recherche montrent qu’il explore des sources techniques hétérogènes avant même de commencer à rédiger.
Fichiers dans .nova/ plutôt qu’une seule conversation massive
La recherche peut facilement générer des dizaines de milliers de tokens, et il serait gaspilleur d’inclure chaque page extraitée ainsi que chaque décision intermédiaire dans chaque appel à outil ultérieur.
À la place, le système crée de petits fichiers de contrôle sous .nova/, tels que guidelines_filenames.json et next_queries.json, et conserve le contenu volumineux dans des fichiers tels que research.md. Le modèle ne reçoit que la partie nécessaire pour l’opération en cours.
Cela est moins « magique » que la « mémoire d’agent », mais bien plus facile à examiner. Lorsqu’une exécution s’arrête en cours de route, le répertoire d’état indique quelles références ont été collectées ou quelle étape de recherche devait suivre. En fait, le système de fichiers sert de cadre approximatif mais fonctionnel entre les différentes phases.
L’auteur est un pipeline
Lorsque research.md existe, la tentation est grande d’envoyer le fichier entier à un grand modèle pour obtenir un article de 2 000 mots. Le projet a évité cela dès le début, car les limites de sortie et la capacité d’attention aux longs contextes rendent difficile de contrôler en même temps la structure, la couverture du code et les citations.
La phase d’écriture divise plutôt ce travail en quatre outils :
generate_article_outlineconvertit le corpus de recherche en une liste de sections avec des budgets de mots et des objectifs de code.draft_article_sectionécrit des sections limitées à la place d’une seule réponse exhaustive.critic_evaluate_articleévalue le brouillon assemblé selon une grille structurée allant de 0 à 100.refine_and_save_articleeffectue les modifications nécessaires, crée la table des matières, vérifie la structure des citations et enregistrearticle.md.
Ce qui est important, c’est que le critique ne se contente pas de dire « c’est bien ». Son output est un retour structuré sur lequel un autre outil peut agir :
{
"overall_score": 85,
"status": "APPROVED",
"category_scores": {
"technical_depth": 27,
"code_quality": 26,
"structure_clarity": 17,
"citation_fidelity": 15
},
"specific_critiques": [
"Section 3.2 mentions timeout handling but omits the asyncio.wait_for example.",
"Citation [14] is cited twice with conflicting author names."
],
"required_revisions": [
"Add the timeout example.",
"Deduplicate bibliography reference [14]."
]
}
Cette méthode réduit progressivement le rôle du modèle à chaque étape. Le modèle de planification définit la structure, le rédacteur synthétise une section, le critique cherche les défauts et l’étape de perfectionnement utilise une liste de contrôle. Aucun modèle ne doit effectuer les quatre tâches en même temps lors d’une seule réalisation.
La note de 85 sur 100 n’est pas une mesure scientifique de qualité. Il s’agit d’un signal de contrôle du flux de travail, et l’élément précieux est la liste de critiques et de révisions lisible par la machine.
Une trace d’une véritable chaîne d’écriture montre une instruction structurée, une appel à un modèle de chat et un analyseur Pydantic ; ce type de registre permet de comprendre un pipeline d’écriture autonome a posteriori.
Le code en tant qu’artefact exécutable
Même un article technique bien écrit peut décevoir les lecteurs si son code ne fonctionne pas. C’est pourquoi une troisième phase existe.
Le constructeur ouvre article.md, extrait le code Python qu’il contient, valide chaque bloc à l’aide de ast.parse(), enregistre les extraits vérifiés dans .nova/sandbox/, les exécute en tant que processus enfant tout en capturant stdout et stderr, et permet aux échecs d’être réexaminés à l’aide d’un outil de correction basé sur un LLM.
La analyse et l’exécution statiques détectent des problèmes différents. ast.parse() est peu coûteux et rejette immédiatement le Python invalide. Le processus enfant capture les erreurs en temps de exécution que la syntaxe seule ne peut pas identifier.
def validate_python_syntax(code: str):
try:
ast.parse(code)
return True, None
except SyntaxError as exc:
return False, f"SyntaxError at line {exc.lineno}: {exc.msg}"
result = subprocess.run(
[sys.executable, str(file_path)],
capture_output=True,
text=True,
timeout=5,
env={"PYTHONUNBUFFERED": "1"},
)
Un extrait défectueux est réparé et renvoyé pour validation avant que l’article ne soit compilé. Dans un cas, un article généré contenait pip install google-genai à l’intérieur d’un bloc Python. Cela semblait plausible en Markdown, mais Python l’a immédiatement rejeté. fix_code_snippets a reçu le rapport d’erreur ainsi que le bloc endommagé, a réécrit l’extrait et l’a renvoyé pour validation.
# Broken
pip install google-genai
from google import genai
# Repaired
# Requires: pip install google-genai
import os
from google import genai
client = genai.Client(
api_key=os.environ.get("GEMINI_API_KEY", "mock-key")
)
La boucle de réparation est délibérément maintenue au niveau local. Le modèle n’a pas le droit de réécrire tout l’article simplement parce qu’un extrait a échoué. Il se concentre sur la plus petite unité utile, à savoir le code et son erreur, et les vérifications déterministes restent le mécanisme de contrôle.
En termes simples : article.md est analysé pour détecter du Python entre balises, chaque extrait est interprété, stocké sous .nova/sandbox/ et exécuté dans un processus enfant délimité. En cas d’échec, le code et les informations d’erreur sont rassemblés pour être transmis à fix_code_snippets, et la réparation proposée suit à nouveau le même processus de validation. Le modèle n’approuve jamais sa propre correction. C’est le LLM qui propose, tandis que ast.parse() ainsi que l’exécution prennent la décision. Ce n’est que lorsque la boucle se termine que export_publication_bundle transforme l’artefact vérifié en un fichier HTML autonome.
Une particularité mérite d’être soulignée. La couche d’exécution s’agit d’un exécuteur de sous-processus protégé et non d’un environnement de sandbox sécurisé renforcé. Un délai d’expiration et un processus enfant permettent de détecter efficacement les erreurs de syntaxe, les pannes en temps de exécution ainsi que les blocages. Un code véritablement non fiable nécessiterait une isolation plus stricte, telle que des conteneurs, des noms d’espace, des mécanismes comme seccomp, des microVM ou un service de sandbox dédié, avec des limites pour le système de fichiers, le réseau, les appels système, la CPU et la mémoire.
Deux couches partagées : le routage des modèles et le traçage
La architecture visible se compose de trois phases, mais deux éléments partagés jouent un rôle tout aussi important dans l’utilisation quotidienne.
Routage des modèles tenant compte des quotas
Une séance de recherche peut générer de nombreuses requêtes vers des modèles, ce qui peut facilement épuiser les quotas de la version gratuite. Afin d’éviter une initialisation spécifique au fournisseur dans chaque gestionnaire, la création des modèles est centralisée dans llm_utils.py, avec la configuration du fournisseur et de la clé API stockée dans les paramètres.
Le projet s’est développé autour d’une série de modèles Gemini Flash, Gemini Flash Lite et Llama hébergés sur Groq. Ces modèles spécifiques ne sont pas permanents, mais cela n’a pas d’importance. Chaque outil communique avec une abstraction de modèle, sans que les détails du fournisseur soient intégrés dans le code de recherche et d’écriture.
Les expériences deviennent également moins coûteuses, car le transfert d’une tâche vers un modèle moins cher ou actuellement disponible ne modifie pas le contrat de l’outil MCP.
def get_chat_model(model_id: str, schema=None):
selected = settings.llm_configs[model_id]
identifier = selected["identifier"]
params = selected.get("params", {}).copy()
# resolve provider-specific API key
...
model = init_chat_model(identifier, **params)
return track_langchain_model(model)
Opik rend les exécutions longues inspectables
Les journaux perdent leur utilité dès qu’un serveur MCP appelle un modèle qui à son tour appelle un outil qui écrit un artefact déclenchant un autre modèle. En instrumentant les clients de modèle et les outils MCP avec le suivi d’Opik, cette hiérarchie s’affiche directement, sans avoir à la reconstituer à partir d’instructions d’impression.
Cela présente un avantage pratique : on peut comparer une étape lente de Firecrawl à une appel de modèle, identifier le modèle qui a réellement répondu, voir le chargement reçu par un analyseur, et reprendre le parcours ayant généré un mauvais artefact. Les captures d’écran des traces constituent l’interface de développement pour comprendre le flux de travail, et non une simple décoration.
Ce que le projet enseigne sur la conception d’agents
La capacité à générer un article n’est pas ce qui rend MaxyAI intéressant, puisqu’un modèle de pointe peut déjà transformer une demande en texte convenable. Ce qui se distingue, c’est la manière dont le travail est divisé. L’ingestion de vidéos, l’analyse d’un répertoire, la recherche, l’élaboration d’un plan, la rédaction, la critique, l’extraction de code, la validation, la correction et l’exportation sont chacune des outils distincts. MCP leur fournit une interface commune ; les fichiers sur disque permettent aux différentes étapes d’échanger de grandes quantités d’informations sans avoir à les stocker dans la mémoire de chat, et Opik rend toute la trajectoire visible.
Les points d’extension sont facilement identifiables. Un outil d’ingestion de PDF offre à la phase 1 un autre type de source. Remplacer Tavily n’affecte pas l’outil de rédaction. Une validation en JavaScript peut être ajoutée à la phase 3 sans toucher au moteur de recherche. Changer les fournisseurs de modèles laisse l’interface MCP inchangée.
Le renforcement pour un environnement de production commencerait par une isolation plus stricte dans des environnements sandbox, suivie d’un ensemble d’évaluations standardisées couvrant la recherche, les citations et la qualité du code, de métadonnées plus claires sur les capacités des fournisseurs, ainsi que des points de contrôle durables pendant l’exécution. Même aujourd’hui, le projet souligne qu’un modèle peut choisir l’action suivante, tandis que la qualité du système dépend des outils, des limites, des artefacts et des boucles de rétroaction qui l’entourent.
Trois leçons d’ingénierie s’appliquent également à d’autres systèmes d’agents :
- Les limites des outils sont plus importantes que leur nombre. Accordez à chaque capacité ses propres limites si elle interagit avec un système différent, échoue ou tente à nouveau de manière différente, ou génère un artefact qui sera réutilisé par quelqu’un d’autre.
- Les modèles proposent des solutions, les vérifications déterministes prennent la décision, chaque fois que c’est possible. Un LLM peut corriger un fragment de texte, mais c’est la analyse et l’exécution qui font la décision finale.