What is your developer-dependent CMS actually costing you? Calculate your costs and get a full report
What is your developer-dependent CMS actually costing you? Calculate your costs and get a full report
Search
Expose your published Agility content to AI agents as a read-only MCP server with search_docs and fetch_doc tools, stateless Streamable HTTP, tool-call analytics, and a Next.js example using mcp-handler.
An MCP server lets AI agents such as Claude, ChatGPT, Cursor and GitHub Copilot search and read your content directly, as a tool, instead of scraping your website. This article shows how to build one over your published Agility content, the same way Agility built the Knowledgebase MCP server for these docs.
MCP (Model Context Protocol) is an open protocol for connecting AI applications to tools and data. An MCP server describes a set of tools; an MCP client, inside the AI application, lets the model call them.
Agility already has an MCP server, and it does a different job. Be clear which one you need.
| Agility CMS MCP Server | An MCP server over your content (this article) | |
|---|---|---|
| What it's for | Managing your Agility instance: content, models, pages, media | Answering questions from your published content |
| Who uses it | Your editors and developers, through their AI assistant | Anyone you choose: your customers' agents, your support team, the public |
| Access | Reads and writes, as the signed-in user, with their permissions | Read-only |
| What it reads | Your instance, including staging content | Your search index of published content |
| Who runs it | Agility | You |
The Agility CMS MCP Server changes things in your instance. The server in this article can't: it only searches and reads what you have already published. Connecting both to the same assistant works well. The assistant can look up how something works before it changes it.
Two tools, backed by your search index:
search_docs takes a short query and returns matching pages, each with a title, URL, id and the passage that matched.fetch_doc takes an id from the search results and returns the full page as Markdown.That's the whole interface. Agents are good at running several searches and fetching the pages that look right, so a small, predictable tool set works better than many specialized ones.
Before building it, make sure search returns the right pages for the questions people actually ask. The tools can only be as good as the index behind them. See AI Search Explained for how to split content and measure results, and Indexing Agility Content for Search for keeping the index in sync as editors publish.
The tool description is the only documentation the agent reads. Agents tend to pack every related keyword into one long query, and search handles that badly. Say what a good query looks like, give examples, and tell the agent to search several times for a broad question. Agility's server asks for "3 to 8 words or one short question" and shows one good and one bad example.
Descriptions are sometimes ignored. If a query is longer than about 12 words, don't run it. Return a short message explaining that the query is too broad and showing what to send instead. The agent reads the message and retries with shorter queries, which return better results than the long one would have. Log the rejection, so you can see how often it happens.
Don't refuse real questions, though. If your engine handles sentences, as hybrid and vector search do, a 13-word question can be a perfectly good query: a strict 12-word limit refused 2 of the 51 questions in our test set before any search ran. Function words (the, my, what, when) tell a question from a keyword list. Search for People and for AI Agents has the check.
Even short queries from agents are often full sentences, such as "is it possible to publish a page at a set time". If your engine requires every word to match by default, sentences return nothing. On Algolia, four query settings fixed this for the Agility docs: queryLanguages: ["en"], removeStopWords: true, ignorePlurals: true and removeWordsIfNoResults: "allOptional". Engines with vector or hybrid search handle sentences without extra settings.
Return each result as a short Markdown block: a heading, the page's full public URL, its id for fetch_doc, and the matching passage. Agents read Markdown well, and the URL lets them cite the page. For fetch_doc, return the page body as clean Markdown. If your site serves Markdown twins, that is exactly what to return.
MCP tool annotations describe a tool's behaviour to the client. Set readOnlyHint: true on both tools, so clients know they don't change anything. Annotations are hints for the client, not a security control. The server is read-only because the code is.
Host the server as a normal HTTPS endpoint using the Streamable HTTP transport, and keep it stateless: every request is handled on its own, with no session to store. That fits serverless hosting such as Vercel, scales without shared state, and works with the major clients.
Two details matter in practice:
Accept: application/json, text/event-stream, and answers 406 Not Acceptable if either type is missing. Some clients send only application/json. Agility's server hit this in production. The example below adds the missing type before handling the request.405 Method Not Allowed, which clients treat as "no stream available". mcp-handler 2.x does this for you.mcp-handler turns an MCP server definition into a Web-standard request handler you can mount as a Next.js route. Version 2.x (2.2.0 at the time of writing) is built on version 2 of the official MCP TypeScript SDK. It serves the 2026-07-28 MCP specification and falls back to stateless Streamable HTTP for older clients, from the same handler.
npm install mcp-handler@^2 @modelcontextprotocol/server@^2 zod@^4
On mcp-handler 1.x? Version 1.x pairs with
@modelcontextprotocol/sdk1.x and has a different API (server.tool(...)and abasePathoption). Version 2.x usesserver.registerTool(...)with az.object()input schema and is mounted at whatever route you choose. See the package's migration notes before upgrading.
The route uses the same searchContent and fetchContent functions as the chatbot example. Implement them with your search engine.
// app/api/mcp/route.ts
import { createMcpHandler } from "mcp-handler"
import { z } from "zod"
import { searchContent, fetchContent } from "@/lib/search/retrieval"
const MAX_QUERY_WORDS = 12
function text(value: string) {
return { content: [{ type: "text" as const, text: value }] }
}
// Replace with your analytics client. Zero-result queries are your content-gap list.
function trackToolCall(tool: string, props: Record<string, unknown>) {
console.info(JSON.stringify({ event: "mcp_tool_call", tool, ...props }))
}
const handler = createMcpHandler(
(server) => {
server.registerTool(
"search_docs",
{
title: "Search documentation",
description: [
"Search the Example Product documentation with a short query.",
"Good queries are 3 to 8 words or one short question, about one concept.",
"For a broad question, call search_docs several times with short related queries, then call fetch_doc on the best results.",
'Good: "preview content before publishing". Avoid: "preview staging production workflow environments publishing ci cd".',
"If a search returns nothing useful, retry with a shorter or different phrase before concluding the docs don't cover it.",
].join("\n"),
inputSchema: z.object({
query: z.string().min(1).describe("A short search query: 3 to 8 words, or one short question."),
}),
annotations: { readOnlyHint: true, openWorldHint: false },
},
async ({ query }) => {
const words = query.trim().split(/\s+/).filter(Boolean).length
if (words > MAX_QUERY_WORDS) {
trackToolCall("search_docs", { query, blocked: "query_too_long", words })
return text(
`This query has ${words} words, which is too broad. Search works best with short queries. ` +
"Retry with several searches of 3 to 8 words, each about one concept, then call fetch_doc on the best results.",
)
}
const hits = await searchContent(query, { top: 10 })
trackToolCall("search_docs", { query, hits: hits.length, zero_results: hits.length === 0 })
if (hits.length === 0) {
return text(`No results for "${query}". Try a shorter query or a synonym.`)
}
return text(
[
`# Results for "${query}"`,
...hits.map((h, i) => `## ${i + 1}. ${h.title}\nURL: ${h.url}\nid: ${h.id}\n\n${h.snippet}`),
].join("\n\n"),
)
},
)
server.registerTool(
"fetch_doc",
{
title: "Fetch document",
description: "Fetch the full Markdown of one documentation page by the id that search_docs returned. Use it before quoting or citing a page.",
inputSchema: z.object({ id: z.string().min(1).describe("A page id from search_docs results.") }),
annotations: { readOnlyHint: true, openWorldHint: false },
},
async ({ id }) => {
const doc = await fetchContent(id)
trackToolCall("fetch_doc", { id, found: Boolean(doc) })
if (!doc) return { ...text(`No page with id ${id}. Search again to get a current id.`), isError: true }
return text(`# ${doc.title}\nURL: ${doc.url}\n\n${doc.markdown}`)
},
)
},
{ serverInfo: { name: "example-docs", version: "1.0.0" } },
)
// Some clients send only "Accept: application/json". The transport requires both
// types and would answer 406, so add the missing one before handing the request on.
async function POST(req: Request) {
const accept = req.headers.get("accept") ?? ""
if (!accept.includes("application/json") || !accept.includes("text/event-stream")) {
const headers = new Headers(req.headers)
headers.set("accept", "application/json, text/event-stream")
req = new Request(req.url, { method: "POST", headers, body: await req.text() })
}
return handler(req)
}
// mcp-handler answers GET with 405 for clients on the 2025 transport, which they read as "no event stream".
export { POST, handler as GET }
This serves the MCP server at /api/mcp. If your Next.js app uses a basePath (this docs site uses /docs), the public URL includes it.
Run the official inspector, choose Streamable HTTP, enter http://localhost:3000/api/mcp, connect, and list the tools:
npx @modelcontextprotocol/inspector
Or call it with curl. This sends only Accept: application/json, to check the header fix:
curl -s http://localhost:3000/api/mcp \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}'
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/api/mcp # GET: expect 405
Clients that support remote MCP servers over Streamable HTTP need only the URL. For example, in Claude Code:
claude mcp add --transport http example-docs https://www.example.com/api/mcp
Or in Cursor's .cursor/mcp.json:
{
"mcpServers": {
"example-docs": { "url": "https://www.example.com/api/mcp" }
}
}
In ChatGPT, add the URL under Settings > Connectors. The setup steps for other clients, including GitHub Copilot in VS Code, follow the same pattern as the Agility CMS MCP Server installation guide, using your server's URL.
Log every tool call with the query, the number of results and whether it returned nothing. Agility's server sends one analytics event per call with query, hits and zero_results.
Log queries, not personal data, and keep analytics calls off the critical path so a slow analytics service doesn't slow the tools.
Agents depend on your tool names, inputs and output format, not on your search engine. Keep the contract stable: search_docs with a query, fetch_doc with an id, Markdown back. Then you can change what's behind it without breaking anyone. The Agility docs MCP keeps its search engine behind one setting: in October 2026 it moved from Algolia to Azure AI Search's hybrid search with no change to the tools, and it falls back to Algolia if Azure fails. See Search for People and for AI Agents.
Two practical rules follow:
fetch_doc accept the ids an older engine returned for a while. An agent may fetch an id from a search it ran earlier.mcp-handler includes withMcpAuth for verifying bearer tokens. Public documentation, like Agility's, needs none.