cd /news/ai-agents/how-to-build-a-read-only-mcp-server-… · home › topics › ai-agents › article
[ARTICLE · art-152955] src=dev.to ↗ pub= topic=ai-agents verified=true sentiment=↑ positive

How to Build a Read-Only MCP Server for Your Website

A developer published a guide for building a read-only Model Context Protocol (MCP) server that lets AI assistants search and read a public content site through explicit tools rather than scraping pages. The design uses a stateless JSON-RPC endpoint, a build-time index generated from the same data as the published pages, and Markdown versions of articles, exposing only public material and returning canonical source URLs. The guide argues this approach gives assistants cleaner content and fresher results than crawling, and positions it as a logical step beyond llms.txt for sites that want to be cited by AI.

by read9 min views1 publishedOct 19, 2026

In brief

A read-only MCP server lets AI assistants search and read public website content through explicit tools instead of scraping pages. For a content site, a stateless JSON-RPC endpoint, a build-time index and Markdown versions of published pages are often enough. The server should expose only public material, write nothing, return canonical source URLs and remain independent of the CMS or production database.

Crawl access, structured data and an llms.txt file remain useful AI-discovery foundations. They are passive, however: an assistant still has to locate a page, remove the surrounding layout and infer which passage answers the question. A read-only MCP server makes the content model explicit, so the assistant can search, retrieve and cite the source through purpose-built tools.

The Model Context Protocol changes that interaction. Instead of relying only on crawling, a content website can offer focused tools: search published articles, read a source page, list public services or look up glossary terms. The assistant receives structured results with canonical URLs to cite. This guide uses a generic endpoint at https://example.com/mcp. The rest of this guide covers the design decisions, protocol details and security controls needed to apply the same pattern to public content.

An MCP server is usually described as a way to connect AI to business systems: a CRM, a ticketing tool, a database. For those, the tools change data and need careful permissions. A publication is the opposite case. Everything is public, nothing should change, and the value is in being found and quoted accurately.

That makes it the safest and simplest MCP server you can build, and still a useful one. Here is what changes for an assistant when ya content website offers tools instead of pages:

Crawling pagesCalling MCP toolsFinding contentWeb search, then guessing which page answers the questionsearch_articles returns ranked matches with summaries and URLsReading contentFetch HTML, strip navigation, ads and scriptsget_article returns clean Markdown with title, dates and FAQAccuracyLayout text and menus leak into answersOnly the article body, with the canonical URL to citeDiscovering offersRarely reaches service pageslist_services and get_service describe them directlyFreshnessDepends on when the page was crawledUpdated with every deploy

It also fits a wider shift. If you have read MCP vs REST APIs: when enterprise architects should use each, the argument is the same here: MCP is the interface designed for models, and REST is designed for developers. For a site that wants to be cited by AI, offering the model-shaped interface is the logical next step after llms.txt.

For a static or hybrid content website, set three constraints before writing code:

Those constraints lead to a very small design with three parts:

The build step is a normal Astro endpoint that reads the same data as the pages. Because it runs with the build, the index can never disagree with what is published:

src/pages/mcp-index.json.ts

export const GET: APIRoute = async () => {
  const { posts, pages, caseStudies, glossary, topics, site } = await getData()
  const articles = posts
    .filter((p) => !p.seo?.noindex)
    .map((p) => ({
      slug: p.slug,
      title: p.title,
      excerpt: p.excerpt ?? '',
      topic: p.topic.slug,
      tags: p.tags.map((t) => t.title),
      published: p.publishedAt.toISOString().slice(0, 10),
      questions: (p.aeo?.faq ?? []).map((f) => f.question), // what each article answers
      url: `/blog/${p.slug}/`,
      markdown: `/blog/${p.slug}.md`,
    }))
  // …services, case studies, glossary and topics the same way
  return new Response(JSON.stringify({ site, topics, articles /* , … */ }), {
    headers: { 'Content-Type': 'application/json; charset=utf-8' },
  })
}

Including each article’s FAQ questions in the index turned out to be the single best decision for search quality. Visitors ask assistants questions, and those questions often match the FAQ an article already answers far better than they match its title.

Tool design matters more than protocol details. A model chooses tools from their names and descriptions alone, so each one needs a single clear job, a description that says when to use it, and an input schema with examples. A focused content server can expose a small set of tools:

ToolWhat it doesInputsearch_articlesRanked matches by keywords or a question, with summary, topic, date and URLquery, optional topic, limitget_articleFull article as Markdown: title, author, dates, body and FAQsluglist_articlesMost recent articles, optionally for one topicoptional topic, limitlist_topicsTopics covered, with descriptions and article countsnonelist_servicesConsulting services with what each coversnoneget_serviceFull service page as Markdownsluglist_case_studiesCase studies with industry and measured resultsnonedefine_termClosest glossary definitions for a termtermget_contact_optionsProfile, contact and booking links to share with the usernone

Three patterns made the tools work well in practice:

Each tool also declares MCP annotations so clients know it is safe to call without asking the user:

worker/mcp.ts

const TOOLS = [
  {
    name: 'search_articles',
    title: 'Search articles',
    description:
      'Search articles by keywords (title, summary, tags and the questions each article answers). ' +
      'Returns the best matches with summary, topic, date and URL.',
    inputSchema: {
      type: 'object',
      properties: {
        query: { type: 'string', description: 'Keywords or a question, e.g. "cloud cost optimization"' },
        topic: { type: 'string', description: 'Optional topic slug from list_topics' },
        limit: { type: 'integer', minimum: 1, maximum: 20, default: 8 },
      },
      required: ['query'],
    },
  },
  // …eight more
].map((tool) => ({
  ...tool,
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
}))

MCP servers can run locally over standard input and output, or remotely over HTTP. A website needs the remote option, which the current specification calls Streamable HTTP. It sounds more complicated than it is. For a stateless, read-only server the rules come down to a handful:

/mcp. application/json or open an event stream. Tools that answer instantly don’t need streaming, so every response here is plain JSON.notifications/initialized) are acknowledged with an empty 202 Accepted. Mcp-Session-Id header. With those rules, the core of the server is a switch over four methods. The official SDK is excellent for stateful servers, but a small read-only endpoint can be clearer to audit with an explicit handler. Choose the approach that makes every protocol behaviour and access boundary clear to the maintainers.

async function handle(msg: Rpc, origin: string, assets: Fetcher) {
  const reply = (result: unknown) => ({ jsonrpc: '2.0', id: msg.id ?? null, result })
  if (msg.id === undefined || msg.id === null) return null // notification → 202

  switch (msg.method) {
    case 'initialize':
      return reply({
        protocolVersion: negotiate(msg.params?.protocolVersion),
        capabilities: { tools: { listChanged: false } },
        serverInfo: { name: 'content-mcp', title: 'the example website', version: '1.0.0' },
        instructions: 'Search first, then read the full Markdown. Cite the page URL.',
      })
    case 'ping':
      return reply({})
    case 'tools/list':
      return reply({ tools: TOOLS })
    case 'tools/call':
      return reply(await callTool(msg.params.name, msg.params.arguments ?? {}, origin, assets))
    default:
      return { jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: 'Method not found' } }
  }
}

Two details are worth copying. First, negotiate the protocol version: if the client asks for a version you support, echo it; otherwise return your latest. Second, return tool results both as text and as structuredContent, so newer clients get typed data while older ones still get a readable answer.

With around forty articles, a vector database would be overkill. A weighted keyword score over the index is fast, predictable and easy to tune:

worker/site-index.ts

function score(article: Article, terms: string[], phrase: string): number {
  const fields: [string, number][] = [
    [article.title.toLowerCase(), 6],
    [article.tags.join(' ').toLowerCase(), 4],
    [article.questions.join(' ').toLowerCase(), 3], // the FAQ each article answers
    [article.excerpt.toLowerCase(), 2],
  ]
  let total = phrase && article.title.toLowerCase().includes(phrase) ? 12 : 0
  let matched = 0
  for (const word of terms) {
    const hits = fields.filter(([text]) => new RegExp(`\\b${word}`).test(text))
    hits.forEach(([, weight]) => (total += weight))
    if (hits.length) matched++
  }
  // Prefer articles that match most of the words, not one word many times.
  return terms.length ? total * (matched / terms.length) : 0
}

The last line is the important one. Multiplying by the share of query words matched stops an article that mentions “cloud” fifteen times from beating the article that actually answers “how do I reduce cloud costs”. In testing, that query returns the FinOps playbook first and the cloud maturity article second, which is exactly what a human editor would choose.

When to move to semantic search

Keyword scoring works well up to a few hundred articles with good titles, tags and FAQ questions. Beyond that, or when readers use very different words from yours, add embeddings (for example Workers AI with Vectorize) and keep the same tool interface. The tools don’t change; only the ranking does.

A public MCP server is an open door by design, so the question is what can come through it. For a read-only content server the answer should be “nothing you haven’t already published”. These are the controls I applied:

If your server ever reads private data or performs actions, all of this changes: you need authentication (MCP specifies OAuth 2.1 for remote servers), per-user permissions and careful prompt-injection defences. The AI browser agents security playbook covers that side. The discipline here is to keep the public server so limited that none of it is needed.

A server nobody knows about does nothing. Three small additions make it discoverable:

/.well-known/mcp/server-card.json describes the server, its transport, endpoint and tools. Scanners such as Cloudflare’s AI readiness check look for it. The official MCP Inspector is the quickest way to check a remote server: run it, choose Streamable HTTP and enter the URL. For automated checks, plain curl is enough because the server answers with JSON:

Terminal

curl -s https://example.com/mcp \
  -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1"}}}'

curl -s https://example.com/mcp \
  -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search_articles","arguments":{"query":"saas authentication providers","limit":3}}}'

npx @modelcontextprotocol/inspector

A practical test plan covers the handshake, a notification that returns 202, tool discovery, an obvious search result, source-page retrieval, glossary lookup, an unknown tool, malformed JSON and a browser GET. Finally, connect a real assistant and ask a question the content answers. That last check reveals whether tool names, descriptions and schemas guide model behaviour clearly.

Running cost is effectively zero: tool calls are ordinary Worker requests that read static files, and the index is cached in memory for a minute. Build time increased by well under a second. The maintenance cost is also near zero, because the index regenerates with every deploy and the tools read the same Markdown the site already publishes for AI crawlers.

What it changes is harder to measure today, because few assistants connect to arbitrary MCP servers by default yet. But the direction is clear. Answer engines are moving from reading the web to calling it, and the sites that offer clean tools will be the easiest to use and quote. Building one now costs a day and keeps the option open.

AI integration

For any MCP server, match the architecture, permissions and rollout plan to the sensitivity of the data and the consequences of each tool call.

The full picture of how MCP fits into enterprise systems is in Model Context Protocol: the future of enterprise AI integration. For a publication, though, the read-only server is the place to start: small, safe and immediately useful to every assistant that connects to it.

Originally published at phpscientist.com.

── more in #ai-agents 4 stories · sorted by recency
── more on @model context protocol 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/how-to-build-a-read-…] indexed:0 read:9min 2026-10-19 · —