Build an MCP server for your docs site
Learn · guide · AI System
You are here: Learn → Guides → AI System → Build an MCP server
This guide is for you if: you have a docs/content site and want AI agents (Claude, Cursor…) to search and read it directly — not guess from training data.
Worked example: this page follows the guide template and formatting toolkit. It describes ZajLibrary’s live server at library.zajapps.com/api/mcp.
The Model Context Protocol (MCP) lets an AI agent call your service as a set of tools. For a docs site, that means search + full-page read instead of scraping. If your content is already structured Markdown, the hard part is done — an MCP server is a thin adapter over content you already have.
This guide builds a remote (hosted) MCP server in-repo so it deploys with the site at one URL.
What you’ll learn
Section titled “What you’ll learn”After this guide you will understand:
- What MCP exposes — tools, resources, prompts — and which ones a docs site needs
- Build-time index + on-demand function — keep static pages static; only
/api/mcpruns server-side - Keyword + semantic search — hybrid ranking without a vector database at small scale
- Operational gotchas — embedding at build vs runtime, deterministic indexes, graceful degradation
Before you start
Section titled “Before you start”- Prerequisites:
- A static or mostly-static docs site (Astro + Starlight fits well)
- Deploy target that supports one serverless function (ZajLibrary uses Vercel)
- Node 22+ and familiarity with
npm run build
- Time: ~25 min read · ~2–4 h to implement on your own repo
- See also: Guide template · Examples shelf · Visuals shelf
What an MCP server actually is
Section titled “What an MCP server actually is”An MCP server exposes three kinds of things to a connected agent. For a docs site you mostly need tools:
| Primitive | For a docs site |
|---|---|
| Tools (functions the agent calls) | search_library, get_document, list_contents |
| Resources (data the agent reads) | optional — the docs themselves |
| Prompts (templates) | optional |
The agent connects, lists your tools, and calls them over JSON-RPC. Relationship at a glance:
graph LR Agent["AI agent<br/>(Claude / Cursor)"] -->|"JSON-RPC over HTTP"| Server["Your MCP server<br/>/api/mcp"] Server --> T1["search_library"] Server --> T2["get_document"] Server --> T3["list_contents"] T1 --> Index[("Content index")] T2 --> Index T3 --> IndexKey terms (first use):
- Tool — a named function the agent invokes with arguments
- Index — JSON (and optional vectors) built from your Markdown at build time
The architecture: index at build, serve on demand
Section titled “The architecture: index at build, serve on demand”Pattern: build a search index once at build time, serve it from one on-demand function. Every doc page stays prerendered; only the MCP endpoint runs server-side.
graph TB subgraph Build ["Build time"] Content["content/docs/**.md"] --> Gen["index generator"] Gen --> Docs["docs.json<br/>(keyword corpus)"] Gen --> Vecs["vectors.json<br/>(embeddings)"] end subgraph Runtime ["Runtime (one function)"] Endpoint["/api/mcp"] --> Search["search engine"] Search --> Docs Search --> Vecs end Agent["AI agent"] -->|"tools/call"| EndpointOn ZajLibrary the pieces are:
@astrojs/vercel— doc pages static;/api/mcpis a functionmcp-handler— fetch-native MCP over HTTPMiniSearch— keyword search (no API key)- Cosine similarity — semantic search over committed embeddings
Repo layout (after you wire it)
Section titled “Repo layout (after you wire it)”Directoryapp/
Directoryscripts/
- build-mcp-index.ts
- test-mcp-client.ts
Directorysrc/
Directorymcp/
- search.ts
- embed.ts
Directorygenerated/
- docs.json
- vectors.json
Directorypages/
Directoryapi/
- [transport].ts
After this shape exists, npm run dev and npm run build both run the index generator first (predev / prebuild in package.json).
Build it (step by step)
Section titled “Build it (step by step)”Follow in order. Each step ends with something you can verify before moving on.
1. Generate a content index at build time
Section titled “1. Generate a content index at build time”Walk src/content/docs/**, strip each file to clean text, and write JSON the function can load.
Why: the function must not parse Markdown on every request — that would be slow and brittle.
- Add a generator script (e.g.
scripts/build-mcp-index.ts) that outputssrc/mcp/generated/docs.json. - Derive URLs the same way Astro does — slug from path + frontmatter; no hand-rolled guesses.
- Wire
prebuild/predevso the index refreshes before dev and production builds. - Verify slugs: after
npm run build, every index URL must match a built page.
cd appnpm run build:index # Expected: docs.json written under src/mcp/generated/- Expected:
src/mcp/generated/docs.jsonexists; line count ≈ number of doc pages
2. Expose tools over one on-demand endpoint
Section titled “2. Expose tools over one on-demand endpoint”Put the handler at /api/mcp and mark the route prerender = false.
Why: MCP is request-time; everything else stays static.
- Create
src/pages/api/[transport].ts(or equivalent) withexport const prerender = false. - Register tools with
createMcpHandler— e.g.search_library,get_document,list_contents. - Validate inputs with Zod schemas so agents get clear errors.
- Point handlers at the generated index from step 1.
3. Add keyword and semantic search
Section titled “3. Add keyword and semantic search”Two strategies, fused at query time:
| Strategy | Library | Best for | Infra |
|---|---|---|---|
| Keyword | MiniSearch | Exact terms, titles | None — ships with build |
| Semantic | Embeddings + cosine | Meaning, paraphrase | Optional vectors file |
| Hybrid | Reciprocal-rank fusion | Production default | Both indexes |
Implementation notes:
- Keyword only — enough for v1; server always works
- Semantic — embed chunks at build; embed query at runtime; brute-force cosine is fine for hundreds of docs
- Degrade gracefully — if
vectors.jsonis empty, fall back to keyword only and report capability in tool metadata
4. Deploy and connect
Section titled “4. Deploy and connect”Deploy as usual; the function ships beside static pages.
claude mcp add --transport http zajlibrary https://library.zajapps.com/api/mcp# Expected: client lists search_library, get_document, …- Expected: agent can call
search_libraryand receive real doc URLs from your index
Gotchas worth knowing
Section titled “Gotchas worth knowing”These cost real time on the ZajLibrary build — learn them for free:
- CI may have cloud credentials at build time. Vercel injects OIDC during builds — an “embed if key available” check tried to embed the whole corpus every deploy and hit rate limits. Make corpus embedding an explicit, opt-in, local step (
npm run embed); commitvectors.json. - Runtime query embedding can be keyless on Vercel — OIDC can embed queries via AI Gateway with no stored API key; only the one-time corpus embed needs a key locally.
- Keep the generated index deterministic — no timestamps — so git diffs stay clean and fresh clones work without re-running the generator.
- Self-describe capability — have the server report whether semantic search is enabled so clients see true state.
Dev notes — where to look when something fails
- Index empty or stale: run
npm run build:index; checksrc/mcp/generated/docs.jsonmtime - MCP 404 locally: ensure dev server restarted after adding
src/pages/api/; route is/api/mcp - Semantic always off: confirm
vectors.jsonexists and is non-empty; runnpm run embedlocally withMCP_EMBED=1 - Test without Claude:
npx tsx scripts/test-mcp-client.tsagainst local or preview URL
Summary
Section titled “Summary”- MCP gives agents tools to search and read your docs — not a second CMS
- Build the index at build time; serve it from one function at
/api/mcp - Keyword + optional semantic hybrid covers exact and fuzzy queries; degrade gracefully
- Opt-in embedding at build; commit vectors; verify slugs against real build output
Checklist (before you call it done)
Section titled “Checklist (before you call it done)”-
docs.jsonregenerates on every build and matches built page URLs -
/api/mcpresponds with tool list over HTTP -
search_libraryreturns real titles and URLs from your content - Semantic path documented — on or off — with no silent failure
- No secrets in repo; placeholders only in docs
Create a Claude Code plugin — package the server so others install it in one command.