跳到正文
DEV Community· Senthil Kumar MS·· 3 小時前AI 評分60

如何為網站建立唯讀 MCP 伺服器

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

AI 導讀

文章示範如何為內容網站建立唯讀 MCP 伺服器,讓 AI 助手透過專用工具搜尋、讀取及引用公開內容,而非只靠爬取網頁。示例採用無狀態 Streamable HTTP、部署時建立索引並以 Markdown 提供全文,包含 9 個唯讀工具、約 350 行 TypeScript 和 60 KB 索引,端點為 https://example.com/mcp。

正文

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.

  • 9 - read-only tools: search, read, list and define
  • ~350 - lines of TypeScript, with no MCP SDK dependency
  • 60 KB - build-time index covering every article, service, case study and glossary term
  • 0 - writes, logins, databases or calls to the CMS at request time

Key takeaways

  • A content site needs a read-only MCP server, not a general API: search, read and list tools cover almost every question an assistant asks.
  • Use the Streamable HTTP transport in stateless mode: every POST is a JSON-RPC request answered with JSON, with no sessions or event streams.
  • Build the index at deploy time and serve full text from Markdown files, so the server never touches your CMS or database.
  • Mark every tool as read-only, cap request sizes and never let tools submit forms or reach private data.
  • Publish a server card and list the endpoint in llms.txt so assistants and scanners can discover it.

Why a Content Site Needs an MCP Server at All

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.

The Architecture: Stateless, Read-Only and Built at Deploy Time

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

🔒 Read-only

  • No tool can create, change or submit anything. Contact options are returned as links for the user, not actions for the agent.

⚡ Stateless

  • No sessions, no server-sent events, no memory between requests. Any edge location can answer any call.

🏗️ No runtime dependencies

  • The CMS is never called at request time. Everything comes from files produced by the build.

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.

Designing Tools an Assistant Will Actually Use

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:

  • **Search, then read. **Search returns short summaries and slugs; reading returns the full text. The model decides how much it needs instead of receiving 3,000 words per result.
  • **Helpful failures. **When nothing matches, the tool says so and suggests the next step (“try broader words, or list_topics”) rather than returning an empty array the model may misread.
  • **URLs everywhere. **Every result carries the canonical page URL, and the server instructions ask the assistant to cite it. That is the whole point for a publication.

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 },
}))

The Protocol: Streamable HTTP Without an SDK

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:

  • **One endpoint. **Clients POST JSON-RPC 2.0 messages to /mcp.
  • **JSON or a stream. **The server may answer with plain application/json or open an event stream. Tools that answer instantly don’t need streaming, so every response here is plain JSON.
  • **Notifications get 202. **Messages without an id (such as notifications/initialized) are acknowledged with an empty 202 Accepted.
  • **Sessions are optional. **A server that keeps no state simply never issues an Mcp-Session-Id header.
  • **GET can be refused. **GET is for servers that push messages, so returning 405 is valid. A public endpoint can instead return a server card or human-readable discovery page when opened in a browser.

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.

worker/mcp.ts

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.

Search Without a Search Engine

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.

Security Choices for a Public, Unauthenticated Server

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:

🔒 Read-only by construction

  • There is no write path in the code at all, not just a permission check. Every tool reads from public build files.

📏 Size limits

  • Request bodies are capped at 64 KB and batched requests at 20 messages, so one call can’t do unbounded work.

🚫 No actions on behalf of users

  • get_contact_options returns links to share. It never submits the contact form or books a call, which stay behind a human check.

🧱 No private data

  • Drafts, unpublished pages and noindex content are excluded from the index at build time.

🌐 Open CORS, no cookies

  • Any origin can call it, but there is no session or credential to steal, so cross-site requests gain nothing.

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.

Discovery: Helping Assistants Find the Server

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

  • *Server card. */.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.
  • **llms.txt. **One line listing the MCP endpoint, so any agent that reads llms.txt learns it exists.
  • **A friendly GET. **Opening /mcp in a browser returns the server card with a hint to use POST, instead of a bare error.

Testing It: Inspector, curl and a Real Assistant

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

# Handshake
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"}}}'

# Call a tool
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}}}'

# Or explore it interactively
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.

What It Costs and What It Changes

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

Planning an MCP server or 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.

Explore AI consulting

A Checklist for Your Own Read-Only MCP Server

Before you ship

  • Generate a content index at build time, excluding drafts and noindex pages.
  • Serve full text as Markdown so tools never parse HTML.
  • Keep tools small and named by job: search, read, list, define.
  • Write descriptions for a model: when to use the tool, with example inputs.
  • Return canonical URLs in every result and ask for citations in the server instructions.
  • Mark every tool readOnlyHint: true, and make it true in the code.
  • Answer notifications with 202 and unknown methods with JSON-RPC errors.
  • Cap request sizes and batch lengths.
  • Publish a server card and list the endpoint in llms.txt.
  • Test with the MCP Inspector, curl and a real assistant.

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.

來源:DEV Community · dev.to