{"slug": "how-to-build-a-read-only-mcp-server-for-your-website", "title": "How to Build a Read-Only MCP Server for Your Website", "summary": "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.", "body_md": "**In brief**\n\nA 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.\n\nCrawl 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.\n\nThe [Model Context Protocol](https://dev.to/glossary/mcp/) 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.\n\nAn 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.\n\nThat 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:\n\nCrawling 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\n\nIt also fits a wider shift. If you have read [MCP vs REST APIs: when enterprise architects should use each](https://dev.to/blog/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.\n\nFor a static or hybrid content website, set three constraints before writing code:\n\nThose constraints lead to a very small design with three parts:\n\nThe 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:\n\n**src/pages/mcp-index.json.ts**\n\n``` js\nexport const GET: APIRoute = async () => {\n  const { posts, pages, caseStudies, glossary, topics, site } = await getData()\n  const articles = posts\n    .filter((p) => !p.seo?.noindex)\n    .map((p) => ({\n      slug: p.slug,\n      title: p.title,\n      excerpt: p.excerpt ?? '',\n      topic: p.topic.slug,\n      tags: p.tags.map((t) => t.title),\n      published: p.publishedAt.toISOString().slice(0, 10),\n      questions: (p.aeo?.faq ?? []).map((f) => f.question), // what each article answers\n      url: `/blog/${p.slug}/`,\n      markdown: `/blog/${p.slug}.md`,\n    }))\n  // …services, case studies, glossary and topics the same way\n  return new Response(JSON.stringify({ site, topics, articles /* , … */ }), {\n    headers: { 'Content-Type': 'application/json; charset=utf-8' },\n  })\n}\n```\n\nIncluding 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.\n\nTool 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:\n\nToolWhat 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\n\nThree patterns made the tools work well in practice:\n\nEach tool also declares MCP annotations so clients know it is safe to call without asking the user:\n\n**worker/mcp.ts**\n\n``` js\nconst TOOLS = [\n  {\n    name: 'search_articles',\n    title: 'Search articles',\n    description:\n      'Search articles by keywords (title, summary, tags and the questions each article answers). ' +\n      'Returns the best matches with summary, topic, date and URL.',\n    inputSchema: {\n      type: 'object',\n      properties: {\n        query: { type: 'string', description: 'Keywords or a question, e.g. \"cloud cost optimization\"' },\n        topic: { type: 'string', description: 'Optional topic slug from list_topics' },\n        limit: { type: 'integer', minimum: 1, maximum: 20, default: 8 },\n      },\n      required: ['query'],\n    },\n  },\n  // …eight more\n].map((tool) => ({\n  ...tool,\n  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },\n}))\n```\n\nMCP 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:\n\n`/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.\nWith 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.\n\n``` js\nasync function handle(msg: Rpc, origin: string, assets: Fetcher) {\n  const reply = (result: unknown) => ({ jsonrpc: '2.0', id: msg.id ?? null, result })\n  if (msg.id === undefined || msg.id === null) return null // notification → 202\n\n  switch (msg.method) {\n    case 'initialize':\n      return reply({\n        protocolVersion: negotiate(msg.params?.protocolVersion),\n        capabilities: { tools: { listChanged: false } },\n        serverInfo: { name: 'content-mcp', title: 'the example website', version: '1.0.0' },\n        instructions: 'Search first, then read the full Markdown. Cite the page URL.',\n      })\n    case 'ping':\n      return reply({})\n    case 'tools/list':\n      return reply({ tools: TOOLS })\n    case 'tools/call':\n      return reply(await callTool(msg.params.name, msg.params.arguments ?? {}, origin, assets))\n    default:\n      return { jsonrpc: '2.0', id: msg.id, error: { code: -32601, message: 'Method not found' } }\n  }\n}\n```\n\nTwo 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.\n\nWith around forty articles, a vector database would be overkill. A weighted keyword score over the index is fast, predictable and easy to tune:\n\n**worker/site-index.ts**\n\n```\nfunction score(article: Article, terms: string[], phrase: string): number {\n  const fields: [string, number][] = [\n    [article.title.toLowerCase(), 6],\n    [article.tags.join(' ').toLowerCase(), 4],\n    [article.questions.join(' ').toLowerCase(), 3], // the FAQ each article answers\n    [article.excerpt.toLowerCase(), 2],\n  ]\n  let total = phrase && article.title.toLowerCase().includes(phrase) ? 12 : 0\n  let matched = 0\n  for (const word of terms) {\n    const hits = fields.filter(([text]) => new RegExp(`\\\\b${word}`).test(text))\n    hits.forEach(([, weight]) => (total += weight))\n    if (hits.length) matched++\n  }\n  // Prefer articles that match most of the words, not one word many times.\n  return terms.length ? total * (matched / terms.length) : 0\n}\n```\n\nThe 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.\n\n**When to move to semantic search**\n\nKeyword 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.\n\nA 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:\n\nIf 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](https://dev.to/blog/ai-browser-agents-seo-security-implementation-playbook-2026/) covers that side. The discipline here is to keep the public server so limited that none of it is needed.\n\nA server nobody knows about does nothing. Three small additions make it discoverable:\n\n`/.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.\nThe 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:\n\n**Terminal**\n\n```\n# Handshake\ncurl -s https://example.com/mcp \\\n  -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \\\n  -d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2025-06-18\",\"capabilities\":{},\"clientInfo\":{\"name\":\"test\",\"version\":\"1\"}}}'\n\n# Call a tool\ncurl -s https://example.com/mcp \\\n  -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \\\n  -d '{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"search_articles\",\"arguments\":{\"query\":\"saas authentication providers\",\"limit\":3}}}'\n\n# Or explore it interactively\nnpx @modelcontextprotocol/inspector\n```\n\nA 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.\n\nRunning 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.\n\nWhat 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.\n\n**AI integration**\n\nFor any MCP server, match the architecture, permissions and rollout plan to the sensitivity of the data and the consequences of each tool call.\n\nThe full picture of how MCP fits into enterprise systems is in [Model Context Protocol: the future of enterprise AI integration](https://dev.to/blog/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.\n\n*Originally published at [phpscientist.com](https://phpscientist.com/blog/build-read-only-mcp-server-for-website/).*", "url": "https://wpnews.pro/news/how-to-build-a-read-only-mcp-server-for-your-website", "canonical_source": "https://dev.to/senthil_kr/how-to-build-a-read-only-mcp-server-for-your-website-31n9", "published_at": "2026-10-19 13:39:01+00:00", "updated_at": "2026-10-19 13:44:38.223309+00:00", "lang": "en", "topics": ["ai-agents", "agent-protocols", "ai-tools", "developer-tools", "ai-search"], "entities": ["Model Context Protocol", "Astro"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/how-to-build-a-read-only-mcp-server-for-your-website", "markdown": "https://wpnews.pro/news/how-to-build-a-read-only-mcp-server-for-your-website.md", "text": "https://wpnews.pro/news/how-to-build-a-read-only-mcp-server-for-your-website.txt", "jsonld": "https://wpnews.pro/news/how-to-build-a-read-only-mcp-server-for-your-website.jsonld"}}