Les trois outils MCP essentiels à un site de contenu
Depuis octobre 2025, je n'arrive pas à me sortir quelques questions de la tête :
- Résume l'article sur Code Mode.
- Que faut-il retenir de la série sur les agents IA ?
- Y a-t-il du contenu sur Vite ?
- Quel est le dernier article sur Devoxx France ?
En octobre, j'ai publié l'article
Ces questions m'ont aidé à itérer sur le MCP jusqu'à ce qu'il devienne efficace et utilisable. Aujourd'hui, je veux partager ce que j'ai appris en construisant un MCP pour un site orienté contenu.
Les défis pour les agents
Pour un humain, ces questions sont relativement simples. Parcourir le site et lire son contenu peut prendre du temps, mais c'est la seule difficulté. Pour un agent, c'est une autre histoire.
Un agent doit :
- répondre aussi vite que possible pour éviter de faire attendre l'utilisateur trop longtemps ;
- répondre aussi précisément que possible pour éviter de frustrer l'utilisateur en ne trouvant pas ce qu'il cherche ;
- continuer à répondre efficacement à mesure que le contenu du site augmente ;
- utiliser aussi peu de tokens que possible pour éviter de faire payer trop cher une réponse aux utilisateurs ;
- fonder ses réponses sur le contenu réel du site et identifier la source afin que les utilisateurs puissent les vérifier.
Cela fait beaucoup de contraintes à respecter lors de la conception d'un serveur MCP.
L'approche actuelle
La version finale de mon MCP dispose de trois outils : get_page, search_content et list_pages. Chacun répond à un type de question différent. Examinons-les un par un.
« Résume l'article sur Code Mode. »
Pour répondre à cette question, l'agent doit pouvoir lire le contenu de l'article. De nombreux serveurs MCP que j'ai explorés pendant la création du mien exposaient un outil get_page précisément dans ce but. Mon outil get_page reçoit l'identifiant d'une page et récupère en interne l'URL correspondante pour charger son contenu.
server.registerTool(
'get_page',
{
description: '...',
inputSchema: {
id: z.string().trim().min(1).describe('Identifiant de contenu exact et globalement unique renvoyé par search_content ou list_pages.')
},
},
async ({ id }) => {
const pages = await loadPages()
const page = pages.find(page => page.id === id)
return fetch(`${page.url}.md`)
},
)
!NOTE Il ne s'agit pas de la véritable implémentation de l'outil
get_page. C'est une version simplifiée à l'extrême pour illustrer l'idée. Pour voir l'implémentation complète, consulte mcp.soubiran.dev
Cela couvre la lecture d'une page connue. Reste une question : comment l'agent connaît-il son identifiant ?
« Y a-t-il du contenu sur Vite ? »
Pour répondre à cette question, l'agent doit pouvoir rechercher dans l'ensemble du contenu, y compris les titres, les descriptions et le corps du texte. Nous avons donc besoin d'un outil search_content. Cet outil reçoit une requête en paramètre et utilise en interne la recherche sémantique et la recherche par mots-clés pour retrouver le contenu correspondant. Cela repose sur Cloudflare AI Search.
server.registerTool(
'search_content',
{
description: '...',
inputSchema: {
query: z.string().trim().min(1).describe('Requête pour rechercher du contenu.')
},
},
async ({ query }) => {
const results = await searchContent(query)
return results.map(result => ({
id: result.id,
title: result.title,
description: result.description,
content: result.chunk,
url: result.url,
date: result.date,
}))
},
)
!NOTE Il ne s'agit pas de la véritable implémentation de l'outil
search_content. C'est une version simplifiée à l'extrême pour illustrer l'idée. Pour voir l'implémentation complète, consulte mcp.soubiran.dev
Avec une requête bien choisie, cet outil pourrait également aider à répondre à la première question. Grâce à la recherche par mots-clés, l'agent pourrait rechercher « Code Mode » et récupérer l'identifiant de la page correspondante. Cependant, cette méthode n'est pas assez fiable à elle seule : une requête peut être ambiguë ou ne pas classer la page attendue en première position.
« Quel est le dernier article sur Devoxx France ? »
Cette question est plus complexe. Elle nécessite de comparer les dates des articles. Pour identifier efficacement le dernier article correspondant, l'agent doit pouvoir accéder aux métadonnées du contenu et les analyser avec du code. Sans cette possibilité, il devrait récupérer la liste complète et effectuer lui-même la comparaison.
Cela pourrait fonctionner, mais ce n'est pas efficace. La liste complète du contenu de mon site compte 203 535 caractères, soit environ 60 000 tokens. Bien sûr, elle tiendrait dans la fenêtre de contexte de la plupart des modèles actuels, mais cela consommerait du temps et des tokens sans aucun bénéfice. Cela polluerait également le contexte et compliquerait la recherche d'informations pertinentes par l'agent.
Malgré cela, j'ai tout de même décidé de créer l'outil list_pages. Cependant, il ne fonctionne peut-être pas comme tu l'imagines.
L'outil reçoit un paramètre code. Il permet à l'agent d'écrire du JavaScript à partir de données typées pour filtrer, trier et transformer précisément les informations dont il a besoin. Les Workers dynamiques de Cloudflare exécutent le code dans un environnement léger, sécurisé et isolé.
Par exemple, l'agent pourrait écrire le code suivant pour récupérer le dernier article sur Devoxx France :
async () => {
const query = 'devoxx france'
return pages.data
.filter(page => page.type === 'post')
.filter(page =>
`${page.title} ${page.description ?? ''}`
.toLowerCase()
.includes(query),
)
.sort((a, b) =>
(b.date ?? '').localeCompare(a.date ?? ''),
)
.slice(0, 1)
.map(({ id, title, description, date, url }) => ({
id,
title,
description,
date,
url,
}))
}
!NOTE Pour comprendre ce qu'est Code Mode, lis Code Mode, deux outils et un MCP pour sauver le contexte de ton LLM.
En coulisses, l'outil ressemble à ceci. Garde à l'esprit que la description est l'un des éléments intéressants d'un outil Code Mode.
server.registerTool(
'list_pages',
{
description: '...',
inputSchema: {
code: z.string().trim().min(1).max(20_000).describe('Une fonction fléchée JavaScript asynchrone avec des variables globales pages, talks et infra en lecture seule.'),
},
},
async ({ code }) => {
const result = await executeCode(code)
return result
},
)
!NOTE Il ne s'agit pas de la véritable implémentation de l'outil
list_pages. C'est une version simplifiée à l'extrême pour illustrer l'idée. Pour voir l'implémentation complète, consulte mcp.soubiran.dev
Cette conception respecte-t-elle les contraintes définies au début ? Oui.
J'ai beaucoup appris en concevant ce MCP.
- Réduis autant que possible le nombre d'outils. Utilise des paramètres pour ajouter de la flexibilité à un outil au lieu d'en créer un nouveau. J'aime beaucoup l'approche de GitHub pour son MCP ;
- Utilise tes outils manuellement pour vérifier s'ils peuvent répondre à tes questions. Si ce n'est pas le cas, améliore-les jusqu'à ce qu'ils le puissent ;
- Garde des outils au périmètre suffisamment large, mais assez spécialisés pour éviter qu'ils se marchent sur les pieds ;
- Réduis la quantité d'informations fournies à l'agent. Moins il a de contenu à lire, plus il sera performant ;
- Parfois, l'agent doit orchestrer lui-même les outils.
Je sais que cela représente beaucoup de technologies, AI Search et Dynamic Workers, rien que pour construire un serveur MCP, mais la différence sur la qualité des réponses est réelle. Un MCP incapable de répondre aux questions des utilisateurs a une valeur limitée. Si tu veux construire un MCP pour ton site orienté contenu, j'espère que cet article t'aidera à éviter les erreurs que j'ai commises et à en construire un meilleur.
Comment je suis arrivé à trois outils
J'ai créé la première version du MCP en octobre 2025. C'était le deuxième MCP que je créais ; j'avais utilisé le premier pour explorer le concept dans
Au final, j'ai créé 10 outils rien que pour le contenu de mon site principal :
list_languagesRenvoie un tableau JSON exploitable par une machine contenant toutes les langues prises en charge par le site d'Estéban. Chaque objet comprend un « code » (ISO 639-1) et un « name » (nom en anglais). Exemple de réponse : [{"code":"en","name":"English"},{"code":"fr","name":"French"}].list_partsRenvoie un tableau JSON exploitable par une machine contenant toutes les parties (sections) disponibles sur le site d'Estéban. Chaque objet comprend un « id » (chaîne de caractères), un « name » (chaîne de caractères) et une « description » (chaîne de caractères). Exemple de réponse : [{"id":"pages","name":"Pages","description":"All website pages available."},{"id":"blog","name":"Blog","description":"All blog posts available."}].list_pagesRenvoie la liste de toutes les pages disponibles sur le site d'Estéban dans une langue donnée. Chaque page comprend son titre, sa description, son URL et sa date. Utilise le paramètre « language » pour sélectionner la langue (« en » pour l'anglais ou « fr » pour le français, par exemple). La réponse est un tableau JSON d'objets : [{ "title": string, "description": string, "url": string, "uri": string, "date": string }].list_postsRenvoie la liste de tous les articles disponibles sur le site d'Estéban dans une langue donnée. Chaque article comprend son titre, sa description, son URL et sa date. Utilise le paramètre « language » pour sélectionner la langue (« en » pour l'anglais ou « fr » pour le français, par exemple). La réponse est un tableau JSON d'objets : [{ "title": string, "description": string, "url": string, "uri": string, "date": string }].list_seriesRenvoie la liste de toutes les séries disponibles sur le site d'Estéban dans une langue donnée. Chaque série comprend son titre, sa description, son URL et sa date. Utilise le paramètre « language » pour sélectionner la langue (« en » pour l'anglais ou « fr » pour le français, par exemple). La réponse est un tableau JSON d'objets : [{ "title": string, "description": string, "url": string, "uri": string, "date": string }].list_series_articlesRenvoie la liste de tous les articles d'une série donnée sur le site d'Estéban dans une langue donnée. Chaque article comprend son titre, sa description, son URL et sa date. Utilise le paramètre « language » pour sélectionner la langue (« en » pour l'anglais ou « fr » pour le français, par exemple) et le paramètre « series » pour indiquer l'URI de la série. La réponse est un tableau JSON d'objets : [{ "title": string, "description": string, "url": string, "uri": string, "date": string }].list_projectsRenvoie un tableau JSON exploitable par une machine contenant toutes les catégories de projets d'Estéban, chacune avec un « title » (nom de la catégorie) et un tableau « projects ». Chaque projet comprend : - « name » (chaîne de caractères, par exemple « barbapapazes/code.soubiran.dev »), - « description » (chaîne de caractères), - « stars » (nombre), - « updatedAt » (chaîne de caractères ISO 8601), - « topics » (tableau de chaînes de caractères), - « url » (chaîne de caractères), - « license » (chaîne de caractères, facultative). Exemple de réponse : [ { "title": "Ecosystem", "projects": [ { "name": "barbapapazes/code.soubiran.dev", "description": "Create beautiful images from code.", "stars": 3, "updatedAt": "2025-03-16T21:16:15Z", "topics": ["code", "vue"], "url": "https://github.com/Barbapapazes/code.soubiran.dev" } ] } ]list_talksRenvoie un tableau JSON exploitable par une machine contenant toutes les conférences données par Estéban Soubiran. Chaque conférence comprend : - « name » (titre de la conférence, chaîne de caractères) - « event » (nom de l'événement, chaîne de caractères) - « date » (date ISO 8601, chaîne de caractères) - « url » (URL principale de la conférence, chaîne de caractères) - « pdf_url » (URL PDF des diapositives, chaîne de caractères, facultative) - « thumbnail_url » (URL de la miniature, chaîne de caractères, facultative) - « github_url » (dépôt GitHub, chaîne de caractères, facultative) - « recording_url » (enregistrement vidéo, chaîne de caractères, facultative) Exemple de réponse : [ { "name": "Unpoly pour reprendre le contrôle !", "event": "Devoxx France", "date": "2023-04-12", "url": "https://talks.soubiran.dev/2023-04-12/devoxxfr", "pdf_url": "https://talks.soubiran.dev/2023-04-12/devoxxfr/pdf", "thumbnail_url": "https://talks.soubiran.dev/2023-04-12/devoxxfr/thumbnail.png", "github_url": "https://github.com/Barbapapazes/talks/tree/main/2023-04-12", "recording_url": "https://talks.soubiran.dev/2023-04-12/devoxxfr/recording" } ]list_socialsRenvoie un tableau JSON exploitable par une machine contenant tous les profils d'Estéban sur les réseaux sociaux. Chaque profil comprend : - « name » (nom de la plateforme, chaîne de caractères, par exemple « Twitter ») - « url » (URL du profil, chaîne de caractères) Exemple de réponse : [ { "name": "Twitter", "url": "https://twitter.com/estebansoubiran" }, { "name": "GitHub", "url": "https://github.com/Barbapapazes" } ]get_pageRécupère une page donnée du site d'Estéban. La réponse contient le contenu Markdown de la page.
Merci de me lire ! Je m’appelle Estéban, et j’adore écrire sur le développement web et le parcours humain qui l’entoure.
Je code depuis plusieurs années et j’apprends encore de nouvelles choses chaque jour. Je partage ce que j’apprends, car j’aurais aimé disposer de ressources claires et complètes lorsque j’ai commencé la programmation.
Si vous avez une question ou souhaitez discuter, laissez un commentaire ci-dessous ou contactez-moi sur mes profils sociaux.
J’espère que cet article vous a appris quelque chose d’utile. Partagez-le, laissez un commentaire ou ajoutez une réaction si c’est le cas. Soutenir mon travail sur GitHub.
Suivez-moi
Estéban Soubiran
Ingénieur logiciel, conférencier et passionné d'open source.
Discussions
Chargement des discussions...
Ajouter un commentaire
Vérification de votre session...