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 AI Agents Will Deeply Transform Our Experience With the Web. J'y expliquais avoir construit un serveur MCP et un agent pour discuter avec le contenu de mon site. J'avais réussi à créer une preuve de concept (POC). C'était la première fois que je créais un tel système et j'étais vraiment impressionné. Cependant, il n'a jamais été prêt pour la production.

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 :

  1. répondre aussi vite que possible pour éviter de faire attendre l'utilisateur trop longtemps ;
  2. répondre aussi précisément que possible pour éviter de frustrer l'utilisateur en ne trouvant pas ce qu'il cherche ;
  3. continuer à répondre efficacement à mesure que le contenu du site augmente ;
  4. utiliser aussi peu de tokens que possible pour éviter de faire payer trop cher une réponse aux utilisateurs ;
  5. 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.

typescript
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 ?

Résumé de l'article sur Code Mode

« 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.

typescript
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.

Y a-t-il du contenu sur Vite ?

« 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 :

typescript
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.

typescript
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.

Quel est le dernier article sur Devoxx France ?

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 A Model Context Protocol (MCP) Server for My Website. Je n'avais aucune idée de la manière de l'architecturer, alors j'ai créé tout ce qui me passait par la tête.

Au final, j'ai créé 10 outils rien que pour le contenu de mon site principal :

  • list_languages

    txt
    Renvoie 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_parts

    txt
    Renvoie 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_pages

    txt
    Renvoie 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_posts

    txt
    Renvoie 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_series

    txt
    Renvoie 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_articles

    txt
    Renvoie 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_projects

    txt
    Renvoie 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_talks

    txt
    Renvoie 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_socials

    txt
    Renvoie 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_page

    txt
    Récupère une page donnée du site d'Estéban. La réponse contient le contenu Markdown de la page.
mcp.soubiran.dev/commit/8c544b5adde3450d18b7b6d13b5927030054be7f

La plupart de ces outils suivent le même modèle.

Mon site repose sur VitePress et génère chaque page de manière statique. J'en ai profité pour générer au moment de la construction de nombreux fichiers JSON contenant les informations dont j'ai besoin. Chaque outil de liste effectue ensuite une simple requête HTTP pour récupérer le fichier JSON correspondant.

Par exemple, l'outil list_pages récupère un fichier JSON nommé pages.en.json, ou pages.fr.json selon la langue demandée dans le paramètre, puis renvoie son contenu à l'agent :

typescript
this.server.tool(
  'list_pages',
  '...',
  {
    language: z.string().min(2).max(2).describe('Code de langue des pages de contenu (« en » ou « fr », par exemple)'),
  },
  async ({ language }) => {
    const result = await ofetch(`pages.${language}.json`, {
      baseURL: env.BASE_API_URL,
    })
    return {
      content: [
        {
          type: 'text',
          text: JSON.stringify(result),
        },
      ],
    }
  },
)

Le fichier JSON lui-même était généré au moment de la construction avec un plugin Vite qui encapsulait la logique suivante :

typescript
export async function listEnPages(): Promise<McpGenerator> {
  const pages = await createContentLoader('**/*.md', {
    transform: data => data
      .filter(isContentEn)
      .map(contentMapper),
  }).load()

  return {
    filename: 'pages.en.json',
    content: pages,
  }
}

Je liste tous les fichiers Markdown, je les filtre pour ne conserver que ceux dont j'ai besoin et je les transforme dans la structure JSON correspondante. Le résultat est un nouveau fichier JSON dans le dossier dist, servi comme n'importe quelle autre ressource statique.

Seul l'outil get_page est différent. Il récupère le contenu Markdown brut d'une page.

typescript
this.server.tool(
  'get_page',
  '...',
  {
    url: z.string().min(1).describe('URL de la page à récupérer (« /about » ou « /contact », par exemple)'),
  },
  async ({ url }) => {
    if (url === '/') {
      url = '/index'
    }

    if (url === '/fr/') {
      url = '/fr/index'
    }

    const result = await ofetch(`pages${url}.md`, {
      baseURL: env.BASE_API_URL,
    })
    return {
      content: [
        {
          type: 'text',
          text: JSON.stringify(result),
        },
      ],
    }
  },
)

Comme tu peux t'en douter, cela fait beaucoup trop d'outils. Il y a deux conséquences directes :

  1. trop de descriptions qui polluent le contexte du LLM ;
  2. trop d'outils à gérer efficacement.

Au quotidien, cet ensemble d'outils vaste et redondant compliquait la sélection fiable des outils et leur orchestration par l'agent.

J'ai tiré deux enseignements de cette première expérience :

  1. réduire le nombre d'outils au strict minimum, car il vaut mieux ajouter des paramètres à un outil que d'en créer un nouveau ;
  2. rendre l'orchestration des outils compréhensible uniquement à partir de leurs noms et de leurs descriptions.

En juillet 2026, j'ai continué à itérer sur le MCP. J'ai réduit le nombre d'outils à seulement deux :

  1. get_page ;
  2. search_pages.

L'outil get_page était semblable au précédent : il récupérait le contenu Markdown brut d'une page. En revanche, l'outil search_pages était complètement différent. Il utilisait Cloudflare AI Search pour permettre à l'agent de rechercher le contenu de mon site en langage naturel. Le résultat comprenait à la fois le nom du fichier et l'extrait de contenu correspondant. L'agent pouvait ensuite appeler get_page pour récupérer le contenu complet de la page et répondre à la question. À l'époque, AI Search ne pouvait alimenter son index de recherche qu'à partir de R2. J'ai donc utilisé une GitHub Action et rclone pour remplir un bucket avec le contenu de mon site. L'index de recherche était ainsi reconstruit automatiquement chaque fois que je publiais du nouveau contenu sur mon site.

La suppression de l'outil list_languages a soulevé beaucoup de questions. Cet outil indiquait à l'agent que le site était multilingue. Mais devons-nous vraiment fournir cette information de cette manière ? Devons-nous ajouter un paramètre à chaque outil pour permettre à l'agent de choisir la langue ? Ou devons-nous toujours fournir les deux langues dans la réponse de l'outil ?

À ce moment-là, j'ai décidé de ne fournir à l'agent que le contenu anglais. Les LLM peuvent traduire ce contenu dans d'autres langues.

J'ai également supprimé les projets et les conférences, car je ne savais pas comment les rendre accessibles à la recherche. À cette époque, mon objectif était de supprimer complètement l'idée d'un outil qui renvoie simplement une liste d'éléments.

Cependant, après quelques jours d'utilisation, j'ai réalisé que cette configuration ne pourrait jamais répondre à une question comme « Quel est le dernier article sur Devoxx France ? », et c'était un véritable problème pour moi.

Il me fallait un moyen d'analyser les métadonnées du contenu pour répondre à cette question, et un outil de liste était l'option la plus évidente. En même temps, je ne voulais pas en ajouter un à cause de tous les problèmes expliqués précédemment. J'étais donc bloqué.

Puis, je me suis souvenu du problème de spécification OpenAPI rencontré par Cloudflare lors de l'introduction de son MCP Code Mode. Une spécification OpenAPI est une liste d'endpoints, tout comme les métadonnées de mon contenu sont une liste de pages. Pourquoi ne pas appliquer la même solution ? Je pouvais fournir à l'agent une liste d'articles et lui permettre d'utiliser du code pour trouver ce dont il avait besoin, en ne renvoyant que les informations pertinentes. C'est ainsi qu'est né l'outil list_content.

La dernière itération a renommé get_content en get_page et list_content en list_pages. Le résultat est la conception à trois outils décrite au début de cet article.

Et maintenant ?

Aujourd'hui, le MCP ne dispose que de trois outils :

  • get_page, pour lire le contenu d'une page ;
  • search_content, pour découvrir du contenu en langage naturel ;
  • list_pages, pour analyser par programmation le contenu disponible sur le site.

Chacun répond à un problème différent et, ensemble, ils permettent à l'agent de répondre à n'importe quelle question sur le contenu de mon site.

C'est vraiment fascinant de voir à quel point des questions qui semblent simples sur le papier peuvent devenir un véritable défi. La plupart des serveurs MCP destinés aux sites orientés contenu, comme les sites de documentation, combinent un outil de liste et un outil de lecture, ou un outil de recherche et un outil de lecture, malgré les comportements différents des outils de liste et de recherche.

Il est maintenant temps d'utiliser mon MCP dans des situations réelles. Je vais créer un chat qui y sera connecté. Il me permettra d'ajuster le prompt système et donnera à tout le monde la possibilité de poser des questions sur le contenu de mon site.

Je créerai ensuite des évaluations pour mesurer l'effet de petites modifications apportées au prompt système ou aux descriptions des outils sur la qualité des réponses.


En fin de compte, pour la documentation destinée aux développeurs, j'ai constaté qu'un fichier llms.txt bien entretenu peut constituer un point de départ plus simple et plus efficace qu'un MCP.

Pd

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 maintenant, et j'apprends encore de nouvelles choses chaque jour. J'aime partager mes connaissances avec les autres, car j'aurais aimé avoir accès à des ressources aussi claires et complètes lorsque j'ai commencé à apprendre la programmation.

Si vous avez des questions ou souhaitez discuter, n'hésitez pas à commenter ci-dessous ou à me contacter sur Bluesky, X, et LinkedIn.

J'espère que vous avez apprécié cet article et appris quelque chose de nouveau. N'hésitez pas à le partager avec vos amis ou sur les réseaux sociaux, et laissez un commentaire ou une réaction ci-dessous, cela me ferait très plaisir ! Si vous souhaitez soutenir mon travail, vous pouvez me sponsoriser sur GitHub !

Réactions

Discussions

Ajouter un commentaire

Vous devez être connecté pour accéder à cette fonctionnalité.

Soutenez mon travail
Suivez-moi sur