
This tutorial is part of Build an MCP Server, within The MCP Guide. Want it built for you? See MCP development.
An MCP server for docs search lets an AI assistant answer questions from your own help center, product docs or internal wiki, and cite the page it used. The simplest build needs no vector database. You load your Markdown files into SQLite's built-in full-text search, then expose two tools: one that searches and returns ranked snippets, and one that reads a full page.
This guide builds that server with the official Python SDK, version 2. It also serves each page as an MCP resource, so apps that attach documents as context can use the same server. Everything runs on the Python standard library plus the mcp package.
Checked against: the Python SDK docs and SQLite's FTS5 documentation on 2 November 2026, with mcp 2.3.0 (the latest PyPI release). The code ran locally on 2 November 2026 on Python 3.12 against a three-page sample docs folder.
Key Takeaways
- Two tools beat one:
search_docsreturns short ranked snippets and paths, andread_docreturns a full page only when the model needs it. - SQLite FTS5 with the porter tokenizer handles keyword search and simple word stems, such as "refunds" matching "refund."
- Quote each search word before passing it to
MATCH. FTS5 treats some characters and words as syntax, and quoting avoids errors. - Use the
{+path}URI template for resources with folders in their paths. A plain{path}stops at the first slash. - Treat your docs as untrusted input if many people can edit them. Text in a page can try to steer the model.
When to build your own
Many vendors now run hosted docs-search MCP servers for their own products. If you need public developer docs for common libraries, a hosted service may already cover them; our docs lookup playbook covers that case.
Build your own when the docs are yours: a help center, an internal handbook, policies or runbooks. Keyword search is a good start. If people often ask questions in words your docs do not use, add semantic search later.
Prerequisites
- Python 3.11 or newer. Python's
sqlite3module needs FTS5 compiled into SQLite. Most builds include it; ifCREATE VIRTUAL TABLE ... USING fts5fails, your SQLite lacks it. - A folder of Markdown files, each starting with a
# Titleline. uvorpip, and Node.js 22.19+ for the Inspector.
If you are new to MCP servers, start with lesson 14 of our course.
Install
uv init docs-mcp --python 3.12
cd docs-mcp
uv add "mcp[cli]"
mkdir docs
Copy your Markdown files into docs/. Subfolders are fine.
Minimal working server
Create server.py:
import os
import sqlite3
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Annotated, Any
from pydantic import Field
from mcp.server import MCPServer
from mcp.server.mcpserver import Context
from mcp.server.mcpserver.exceptions import ResourceNotFoundError, ToolError
from mcp.types import ToolAnnotations
DOCS_DIR = Path(os.environ.get("DOCS_DIR", "docs")).resolve()
def build_index() -> sqlite3.Connection:
"""Load every Markdown file into an in-memory SQLite FTS5 index."""
db = sqlite3.connect(":memory:", check_same_thread=False)
db.execute("CREATE VIRTUAL TABLE docs USING fts5(path UNINDEXED, title, body, tokenize='porter')")
for file in sorted(DOCS_DIR.rglob("*.md")):
text = file.read_text(encoding="utf-8")
title = next((line[2:].strip() for line in text.splitlines() if line.startswith("# ")), file.stem)
db.execute("INSERT INTO docs VALUES (?, ?, ?)", (file.relative_to(DOCS_DIR).as_posix(), title, text))
db.commit()
return db
@asynccontextmanager
async def lifespan(server: MCPServer) -> AsyncIterator[sqlite3.Connection]:
db = build_index()
try:
yield db
finally:
db.close()
mcp = MCPServer("docs-search", lifespan=lifespan)
@mcp.tool(annotations=ToolAnnotations(read_only_hint=True, open_world_hint=False))
def search_docs(
query: Annotated[str, Field(min_length=2, description="Plain words, e.g. 'refund window'.")],
ctx: Context[sqlite3.Connection],
limit: Annotated[int, Field(ge=1, le=10)] = 5,
) -> list[dict[str, Any]]:
"""Search the help docs. Returns the best-matching pages with a short snippet and a path for read_doc."""
db = ctx.request_context.lifespan_context
terms = " ".join('"' + word.replace('"', "") + '"' for word in query.split())
try:
rows = db.execute(
"SELECT path, title, snippet(docs, 2, '[', ']', ' ... ', 12) FROM docs "
"WHERE docs MATCH ? ORDER BY bm25(docs) LIMIT ?",
(terms, limit),
).fetchall()
except sqlite3.OperationalError as exc:
raise ToolError(f"Search failed: {exc}") from exc
return [{"path": p, "title": t, "snippet": s} for p, t, s in rows]
if __name__ == "__main__":
mcp.run()
How it works:
- The index is built in the lifespan. The SDK's lifespan guide says the lifespan runs once at startup, and every tool gets what it yields. Restart the server to pick up doc changes.
- FTS5. SQLite's docs describe it as "an SQLite virtual table module that provides full-text search functionality."
path UNINDEXEDstores the path without searching it. tokenize='porter'. The FTS5 docs say the porter tokenizer applies stemming so that, for example, "correction" matches "corrected." It is designed for English.bm25()ranks results. The docs say "The better the match, the numerically smaller the value returned," so ascending order puts the best match first.snippet()returns a short fragment around the matches, with[and]around each hit. The FTS5 docs cap the snippet at 64 tokens; this uses 12.- Quoted terms. The FTS5 docs say strings with characters outside a small set "must be quoted," and that
AND,ORandNOTare operators. Wrapping each word in double quotes, and stripping quotes from the input, keeps model-written queries from causing syntax errors. Quoted words separated by spaces must all appear. check_same_thread=False. The SDK runs plaindeftools in a worker thread, per its Tools guide, so the connection is used from more than one thread. This index is read-only after startup.
Add a read tool and a resource
Add these below search_docs:
@mcp.tool(annotations=ToolAnnotations(read_only_hint=True, open_world_hint=False))
def read_doc(path: str, ctx: Context[sqlite3.Connection]) -> str:
"""Read one full help page by the path returned from search_docs."""
row = ctx.request_context.lifespan_context.execute("SELECT body FROM docs WHERE path = ?", (path,)).fetchone()
if row is None:
raise ToolError(f"No page at {path!r}. Use a path from search_docs.")
return row[0]
@mcp.resource("docs://{+path}")
def doc_resource(path: str, ctx: Context[sqlite3.Connection]) -> str:
"""A help page, for apps that attach docs as context."""
row = ctx.request_context.lifespan_context.execute("SELECT body FROM docs WHERE path = ?", (path,)).fetchone()
if row is None:
raise ResourceNotFoundError(f"No page at {path!r}")
return row[0]
Why both? The SDK's Resources guide explains the split: a tool is something the model decides to call, and a resource is something the application decides to load. Tools work in every host. Resources let apps that support them attach a page directly.
read_doc reads only from the index, never from disk. A request for ../server.py simply finds no row. That is safer than joining a model-supplied path to a folder.
The {+path} template matters. The SDK's URI templates guide says a plain {name} "stops at the first /," and that {+name} captures values that contain slashes. In the test run, docs://{path} failed for docs://billing/plans.md with Unknown resource. Switching to docs://{+path} fixed it.
Test it in memory
Create check.py:
import anyio
from mcp import Client
from server import mcp
async def main() -> None:
async with Client(mcp) as c:
print((await c.call_tool("search_docs", {"query": "refunds"})).structured_content)
print((await c.call_tool("search_docs", {"query": "downgrade plan"})).structured_content)
r = await c.call_tool("read_doc", {"path": "../server.py"})
print(r.is_error, r.content[0].text)
res = await c.read_resource("docs://billing/plans.md")
print(res.contents[0].text[:30])
anyio.run(main)
The output on 2 November 2026, with three sample pages:
{'result': [{'path': 'billing/refunds.md', 'title': 'Refund policy', 'snippet': '# [Refund] policy\n\nCustomers can request a [refund] within 30 days of purchase ... '}]}
{'result': [{'path': 'billing/plans.md', 'title': 'Changing your plan', 'snippet': '# Changing your [plan]\n\nUpgrade or [downgrade] from Settings > Billing. [Downgrades] take effect ... '}]}
True Error executing tool read_doc: No page at '../server.py'. Use a path from search_docs.
# Changing your plan
"refunds" matched "Refund," and "downgrade" matched "Downgrades," thanks to stemming. A query containing quotes and OR returned no results instead of an error.
Test with the MCP Inspector
npx @modelcontextprotocol/inspector --cli \
-e DOCS_DIR=/ABSOLUTE/PATH/docs-mcp/docs \
uv --directory /ABSOLUTE/PATH/docs-mcp run server.py \
--method tools/call --tool-name search_docs \
--tool-args-json '{"query":"password reset"}' --format json
The Inspector docs and our Inspector lesson show how to list resource templates and read resources too.
Connect it to a host
Set DOCS_DIR to an absolute path in the host's config. Our MCP config generator writes the JSON for Claude Desktop, Cursor and VS Code. Then try: "What is our refund window? Cite the page." A good tool description makes the model search first and read the top page.
Deploy notes
- Support teams. A remote server over Streamable HTTP lets a whole team share one index. Add sign-in if the docs are internal. See adding OAuth to a remote MCP server.
- Fresh content. Rebuild the index on deploy, or add a file watcher. For large doc sets, store the index in a file instead of memory.
- Public docs. If you publish a docs server for customers, keep it read-only and rate-limit it.
- Better recall. If keyword search misses too often, add embeddings and blend scores. Keep the same two tools, so hosts and prompts do not change.
Pitfalls
- One giant tool result. Returning whole pages from search fills the context. Return snippets and let the model read one page.
- Raw user text in
MATCH. Unquoted hyphens, parentheses orORcan cause syntax errors or change the query. Quote each term. {path}instead of{+path}. Resource reads fail for any page in a subfolder.- Stale old imports.
mcp.server.fastmcpis gone in SDK v2. Usefrom mcp.server import MCPServer. - Untrusted pages. If anyone can edit a wiki page, they can plant instructions for the model. Our prompt injection guide covers defenses.
Next step
Continue with resources and prompts in the free Learn MCP course. If you want an AI assistant that answers from your help center or policies, eSEOspace MCP development can scope the server and the review process with you.
More in the MCP Guide
Free tools: server.json Validator · MCP Error Code Lookup
Latest research: State of MCP: October 2026
Frequently asked questions
Do I need a vector database?
Not to start. Keyword search with stemming answers many help-center questions well. Add embeddings when users often phrase questions differently from your docs.
Can it search PDFs or Word files?
Convert them to text or Markdown first, then index the text. Keep the original file path in the index so the model can cite it.
How do I make the model cite its source?
Return the path and title with every search result, and say in the tool description that answers should cite the page. Many hosts show tool results, so users can check.
How big can the docs folder be?
An in-memory index suits hundreds or a few thousand pages. For more, write the index to a SQLite file and build it ahead of time.
Why not just expose the docs folder with a filesystem server?
A filesystem server lets the model browse and read files, but it has no ranking. Search returns the best pages first, which saves tokens and time.











