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 AI Agents Will Deeply Transform Our Experience With the Web. I explained that I had built an MCP server and an agent to chat with my website's content. I had successfully created a proof of concept (POC). It was the first time I had created such a system, and I was really impressed. However, it was never production-ready.
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_page tool. 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_content tool. 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_pages tool. 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 A Model Context Protocol (MCP) Server for My Website. I had no idea how to architect it, so I created everything I could think of.
In the end, I created 10 tools just for the content of my main website:
list_languagestxtReturns 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_partstxtReturns 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_pagestxtReturns 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_poststxtReturns 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_seriestxtReturns 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_articlestxtReturns 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_projectstxtReturns 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_talkstxtReturns 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_socialstxtReturns 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_pagetxtFetches a specific page from Estéban\'s website. The response is the Markdown content of the page.
Most of these tools follow the same pattern.
My website is VitePress-based and generates each page statically. I took advantage of this to generate many JSON files containing the information I need at build time. Each list tool then makes a simple HTTP request to fetch its corresponding JSON file.
For example, the list_pages tool fetches a JSON file named pages.en.json—or pages.fr.json, depending on the language requested through the parameter—and returns its content to the agent:
this.server.tool(
'list_pages',
'...',
{
language: z.string().min(2).max(2).describe('Language code for the content pages (e.g., "en", "fr")'),
},
async ({ language }) => {
const result = await ofetch(`pages.${language}.json`, {
baseURL: env.BASE_API_URL,
})
return {
content: [
{
type: 'text',
text: JSON.stringify(result),
},
],
}
},
)The JSON file itself was generated at build time with a Vite plugin that encapsulated the following logic:
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,
}
}I list all the Markdown files, filter them to keep only the ones I need, and map them to the corresponding JSON structure. The result is a new JSON file in the dist folder that is served like any other static asset.
Only the get_page tool is different. It fetches the raw Markdown content of a page.
this.server.tool(
'get_page',
'...',
{
url: z.string().min(1).describe('URL to the page to retrieve (e.g., "/about", "/contact")'),
},
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),
},
],
}
},
)As you can guess, that's way too many tools. There are two direct consequences:
- too much description that pollutes the LLM context.
- too many tools to manage effectively.
In everyday use, this broad, overlapping toolset made reliable tool selection and orchestration harder for the agent.
I learned two lessons from this initial experience:
- reduce the toolset to a minimum, so adding parameters to a tool is better than creating a new one;
- make tool orchestration understandable from the tools' names and descriptions alone.
In July 2026, I continued to iterate on the MCP. I reduced the number of tools to only two:
get_page;search_pages.
The get_page tool was similar to the previous get_page tool: it fetched the raw Markdown content of a page. However, the search_pages tool was completely different. It used Cloudflare AI Search to let the agent search my website's content using natural language. The result included both the filename and the matching content chunk. The agent could then call get_page to fetch the full page content and answer the question. At that time, AI Search could only populate its search index from R2, so I used a GitHub Action and rclone to populate a bucket with my website's content. This automatically rebuilt the search index whenever I pushed new content to my website.
Removing the list_languages tool raised a lot of concerns. This tool indicated to the agent that the website is multilingual. However, should we really provide this information that way? Should we add a parameter to each tool to let the agent select the language? Or should we always provide both languages in the tool output?
At that moment, I decided to provide only English content to the agent. LLMs can translate that content into other languages.
I also removed projects and talks because I didn't know how to make them available to search. At that time, my goal was to completely remove the idea of a tool that simply returns a list of items.
However, after a couple of days working with it, I realized that this configuration could never answer a question like "What's the latest article about Devoxx France?" and that was a real problem for me.
I needed a way to analyze content metadata to answer that question, and a list tool was the most straightforward option. At the same time, I did not want to add one because of all the problems I explained earlier. So, I was blocked.
Then, I remembered the OpenAPI specification problem Cloudflare faced when it introduced its code mode MCP. An OpenAPI specification is a list of endpoints, much like my content metadata is a list of pages. So, what about applying the same solution? I could provide the agent with a list of articles and let it use code to find what it needed, returning only the relevant information. That is how the list_content tool was born.
The final iteration renamed get_content to get_page and list_content to list_pages. The result is the three-tool design described at the beginning of this article.
What's Next
Today, the MCP has only three tools:
get_page, read the content of a page;search_content, discover content using natural language;list_pages, programmatically analyze the website's available content.
Each tackles a different problem and, together, they allow the agent to answer any question about my website's content.
It's truly fascinating to see how questions that seem simple on paper can become a real challenge. Most MCP servers for content-oriented websites, such as documentation sites, combine a list tool and a get tool, or a search tool and a get tool, despite the different behaviors of list and search tools.
Now, it is time to use my MCP in real-world scenarios. I will create a chat connected to it, which will let me tweak the system prompt and allow anyone to ask questions about my website's content.
I will then create evals to see how small changes to the system prompt or tool descriptions affect answer quality.
Ultimately, for developer documentation, I have found that a well-maintained llms.txt file can be a simpler and more effective starting point than an MCP.
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 now, and I'm still learning new things every day. I enjoy sharing my knowledge with others, as I would have appreciated having access to such clear and complete resources when I first started learning programming.
If you have any questions or want to chat, feel free to comment below or reach out to me on Bluesky, X, and LinkedIn.
I hope you enjoyed this article and learned something new. Please consider sharing it with your friends or on social media, and feel free to leave a comment or a reaction below, it would mean a lot to me! If you'd like to support my work, you can sponsor me on GitHub!
Discussions
Add a Comment
You need to be logged in to access this feature.