The Three MCP Tools You Need for a Content-Oriented Website
Since October 2025, I haven't been able to shake a few questions:
- Summarize the article about Code Mode.
- What to keep in mind in the series about AI agents?
- Is there content about Vite?
- What's the latest article about Devoxx France?
In October, I published the article
Those questions helped me iterate on the MCP until it was efficient and usable. Today, I want to share what I learned about building an MCP for a content-oriented website.
Challenges for Agents
For a human, these questions are relatively trivial. It can take time to browse the website and read the content, but that's the only difficulty. For an agent, it's a different story.
An agent must:
- answer as quickly as possible to avoid making the user wait too long;
- answer as accurately as possible to avoid frustrating the user by not finding what they are looking for;
- continue to answer effectively as the website's content grows;
- use as few tokens as possible to avoid making users pay too much for an answer;
- ground answers in the website’s actual content and identify the source, so users can verify them.
That's a lot of constraints to meet when designing an MCP server.
The Current Approach
The final version of my MCP has three tools: get_page, search_content, and list_pages. Each answers a different kind of question. Let's tackle them one by one.
"Summarize the article about Code Mode."
To answer this question, the agent must be able to read the article's content. Many MCP servers I explored while creating mine exposed a get_page tool for exactly that purpose. My get_page tool takes a page ID and internally retrieves the corresponding URL to fetch the page content.
server.registerTool(
'get_page',
{
description: '...',
inputSchema: {
id: z.string().trim().min(1).describe('Exact, globally unique content ID returned by search_content or list_pages.')
},
},
async ({ id }) => {
const pages = await loadPages()
const page = pages.find(page => page.id === id)
return fetch(`${page.url}.md`)
},
)
!NOTE This is not the real implementation of the
get_pagetool. It is an oversimplified version to illustrate the idea. For the full implementation, check mcp.soubiran.dev
That covers reading a known page. The remaining question is: how does the agent know its ID?
"Is there content about Vite?"
To solve this question, the agent must be able to search across all content, including titles, descriptions, and body text. So, we need a search_content tool. That tool takes a query as a parameter and internally uses semantic and keyword search to retrieve matching content. This is done using Cloudflare AI Search.
server.registerTool(
'search_content',
{
description: '...',
inputSchema: {
query: z.string().trim().min(1).describe('Query to search for content.')
},
},
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 This is not the real implementation of the
search_contenttool. It is an oversimplified version to illustrate the idea. For the full implementation, check mcp.soubiran.dev
With a well-chosen query, this tool could also help answer the first question. Thanks to keyword search, the agent could search for "Code Mode" and retrieve the corresponding page ID. However, this is not reliable enough on its own: a query can be ambiguous or fail to rank the intended page first.
"What's the latest article about Devoxx France?"
This one is more complicated. It requires comparing article dates. To identify the latest matching article efficiently, the agent needs access to the content metadata and a way to analyze it with code. Without that capability, it would have to retrieve the full list and perform the comparison itself.
That may work, but it is not efficient. The full list of my website's content is 203,535 characters long, or about 60,000 tokens. Sure, it would fit within the context window of most models today, but it would consume time and tokens for no benefit. It would also pollute the context, making it harder for the agent to find relevant information.
Despite this, I decided to create the list_pages tool anyway. However, it does not work as you might expect.
The tool takes a code input. It lets the agent write JavaScript against typed data to filter, sort, and map exactly the information it needs. Cloudflare Dynamic Workers execute the code in a lightweight, secure, and isolated environment.
For example, the agent could write the following code to retrieve the latest article about 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 To understand what code mode is, read Code Mode, Two Tools, and an MCP Can Save Your LLM Context.
Under the hood, the tool looks like this. Keep in mind that one of the interesting parts of a code mode tool is its description.
server.registerTool(
'list_pages',
{
description: '...',
inputSchema: {
code: z.string().trim().min(1).max(20_000).describe('An async JavaScript arrow function with read-only pages, talks, and infra globals.'),
},
},
async ({ code }) => {
const result = await executeCode(code)
return result
},
)
!NOTE This is not the real implementation of the
list_pagestool. It is an oversimplified version to illustrate the idea. For the full implementation, check mcp.soubiran.dev
Does this design meet the constraints we set at the beginning? Yes, it does.
I learned a lot while designing this MCP.
- Reduce the toolset as much as possible. Use parameters to add flexibility to a tool instead of creating a new one. I really like GitHub's approach to MCP here;
- Use your tools manually to see if they can answer your questions. If not, iterate on them until they can;
- Keep tools broad enough in scope but specialized enough to avoid stepping on each other's toes;
- Reduce the amount of information you provide to the agent. The less it has to read, the better it will perform;
- Sometimes, the agent should orchestrate the tools itself.
I know that it is a lot of technology, AI Search and Dynamic Workers, just to build an MCP server, but it makes a real difference to answer quality. An MCP that cannot answer users' questions has limited value. If you want to build an MCP for your content-oriented website, I hope this article helps you avoid the mistakes I made and build a better one.
How I Arrived at Three Tools
I created the first version of the MCP in October 2025. It was the second MCP I had created; I had used the first to explore the concept in
In the end, I created 10 tools just for the content of my main website:
list_languagesReturns a machine-readable JSON array of all supported languages for Estéban\'s website. Each object includes a "code" (ISO 639-1) and "name" (English name). Example response: [{"code":"en","name":"English"},{"code":"fr","name":"French"}].list_partsReturns a machine-readable JSON array of all available parts (sections) of Estéban\'s website. Each object includes an "id" (string), "name" (string), and "description" (string). Example response: [{"id":"pages","name":"Pages","description":"All website pages available."},{"id":"blog","name":"Blog","description":"All blog posts available."}].list_pagesReturns a list of all available pages on Estéban\'s website for a specified language. Each page includes its title, description, URL, and date. Use the "language" parameter to select the language (e.g., "en" for English, "fr" for French). The response is a JSON array of objects: [{ "title": string, "description": string, "url": string, "uri": string, "date": string }].list_postsReturns a list of all available blog posts on Estéban\'s website for a specified language. Each post includes its title, description, URL and date. Use the "language" parameter to select the language (e.g., "en" for English, "fr" for French). The response is a JSON array of objects: [{ "title": string, "description": string, "url": string, "uri": string, "date": string }].list_seriesReturns a list of all available series on Estéban\'s website for a specified language. Each series includes its title, description, URL, and date. Use the "language" parameter to select the language (e.g., "en" for English, "fr" for French). The response is a JSON array of objects: [{ "title": string, "description": string, "url": string, "uri": string, "date": string }].list_series_articlesReturns a list of all articles within a specified series on Estéban\'s website for a given language. Each article includes its title, description, URL, and date. Use the "language" parameter to select the language (e.g., "en" for English, "fr" for French) and the "series" parameter to specify the series URI. The response is a JSON array of objects: [{ "title": string, "description": string, "url": string, "uri": string, "date": string }].list_projectsReturns a machine-readable JSON array of all project categories for Estéban, each with a "title" (category name) and a "projects" array. Each project includes: - "name" (string, e.g. "barbapapazes/code.soubiran.dev"), - "description" (string), - "stars" (number), - "updatedAt" (ISO 8601 string), - "topics" (array of strings), - "url" (string), - "license" (string, optional). Example response: [ { "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_talksReturns a machine-readable JSON array of all talks given by Estéban Soubiran. Each talk includes: - "name" (title of the talk, string) - "event" (event name, string) - "date" (ISO 8601 date, string) - "url" (main talk URL, string) - "pdf_url" (slides PDF URL, string, optional) - "thumbnail_url" (thumbnail image URL, string, optional) - "github_url" (GitHub repo URL, string, optional) - "recording_url" (video recording URL, string, optional) Example response: [ { "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_socialsReturns a machine-readable JSON array of all social media profiles for Estéban Soubiran. Each profile includes: - "name" (platform name, string, e.g. "Twitter") - "url" (profile URL, string) Example response: [ { "name": "Twitter", "url": "https://twitter.com/estebansoubiran" }, { "name": "GitHub", "url": "https://github.com/Barbapapazes" } ]get_pageFetches a specific page from Estéban\'s website. The response is the Markdown content of the page.
Thanks for reading! My name is Estéban, and I love to write about web development and the human journey around it.
I've been coding for several years, and I'm still learning new things every day. I share what I learn because I would have appreciated clear and complete resources when I started programming.
If you have a question or want to chat, leave a comment below or reach out through my social profiles.
I hope you learned something useful. Share the article, leave a comment, or add a reaction if you did. Support my work on GitHub.
Follow me
Estéban Soubiran
Software engineer, conference speaker, and open source enthusiast.
Discussions
Loading discussions...
Add a comment
Checking your session...