Skip to content
prod e051e98
Browse

Build an MCP server for your docs site

Learn · guide · AI System

You are here: LearnGuidesAI SystemBuild 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.


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/mcp runs 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

  • 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

An MCP server exposes three kinds of things to a connected agent. For a docs site you mostly need tools:

PrimitiveFor 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 --> Index

Key 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"| Endpoint

On ZajLibrary the pieces are:

  1. @astrojs/vercel — doc pages static; /api/mcp is a function
  2. mcp-handler — fetch-native MCP over HTTP
  3. MiniSearch — keyword search (no API key)
  4. Cosine similarity — semantic search over committed embeddings
  • 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).


Follow in order. Each step ends with something you can verify before moving on.

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.

  1. Add a generator script (e.g. scripts/build-mcp-index.ts) that outputs src/mcp/generated/docs.json.
  2. Derive URLs the same way Astro does — slug from path + frontmatter; no hand-rolled guesses.
  3. Wire prebuild / predev so the index refreshes before dev and production builds.
  4. Verify slugs: after npm run build, every index URL must match a built page.
Terminal window
cd app
npm run build:index # Expected: docs.json written under src/mcp/generated/
  • Expected: src/mcp/generated/docs.json exists; 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.

  1. Create src/pages/api/[transport].ts (or equivalent) with export const prerender = false.
  2. Register tools with createMcpHandler — e.g. search_library, get_document, list_contents.
  3. Validate inputs with Zod schemas so agents get clear errors.
  4. Point handlers at the generated index from step 1.

Two strategies, fused at query time:

StrategyLibraryBest forInfra
KeywordMiniSearchExact terms, titlesNone — ships with build
SemanticEmbeddings + cosineMeaning, paraphraseOptional vectors file
HybridReciprocal-rank fusionProduction defaultBoth 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.json is empty, fall back to keyword only and report capability in tool metadata

Deploy as usual; the function ships beside static pages.

Terminal window
claude mcp add --transport http zajlibrary https://library.zajapps.com/api/mcp
# Expected: client lists search_library, get_document, …
  • Expected: agent can call search_library and receive real doc URLs from your index

These cost real time on the ZajLibrary build — learn them for free:

  1. 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); commit vectors.json.
  2. 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.
  3. Keep the generated index deterministic — no timestamps — so git diffs stay clean and fresh clones work without re-running the generator.
  4. 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; check src/mcp/generated/docs.json mtime
  • MCP 404 locally: ensure dev server restarted after adding src/pages/api/; route is /api/mcp
  • Semantic always off: confirm vectors.json exists and is non-empty; run npm run embed locally with MCP_EMBED=1
  • Test without Claude: npx tsx scripts/test-mcp-client.ts against local or preview URL

  • 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

  • docs.json regenerates on every build and matches built page URLs
  • /api/mcp responds with tool list over HTTP
  • search_library returns 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.