Knowledge Base MCP Server
Go beyond tools: expose your notes as MCP resources, add a ranked search tool, and publish reusable prompt templates, so an assistant can read and reason over your own knowledge base.
How it works in the real world
MCP servers can offer three kinds of things, and real servers usually offer all three:
- Resources: read-only data addressed by URI (
notes://python-tips), like files the assistant can open. - Tools: actions with arguments, such as searching those notes.
- Prompts: named templates a user can pick ("summarise this note") that the server fills in with real content.
You will build each one over an in-memory set of notes, then route them all through one dispatcher with proper error codes.
Expose notes as resources
Answer resources/list with one entry per note: uri notes://SLUG, the note's name (its first line without the #) and mimeType text/markdown. Then read notes://mcp-basics and print its text.
The name is NOTES[slug].splitlines()[0].lstrip("# "). For reading, strip the notes:// prefix with uri.removeprefix("notes://").
import json
NOTES = {
"python-tips": "# Python tips\nUse enumerate when you need the index.\nUse a dict for fast lookups.",
"mcp-basics": "# MCP basics\nServers expose tools, resources and prompts.\nClients call them over JSON-RPC.",
"testing": "# Testing\nWrite a failing test first.\nKeep tests fast and independent.",
}
def list_resources():
return [{"uri": f"notes://{slug}", "name": body.splitlines()[0].lstrip("# "),
"mimeType": "text/markdown"} for slug, body in NOTES.items()]
def read_resource(uri):
slug = uri.removeprefix("notes://")
return {"contents": [{"uri": uri, "mimeType": "text/markdown", "text": NOTES[slug]}]}
for r in list_resources():
print(json.dumps(r))
print(read_resource("notes://mcp-basics")["contents"][0]["text"])
Run your code to check this step…
A ranked search tool
Write search(query, limit=2): split the query and each note into lowercase words (re.findall(r"[a-z]+", ...)), score a note by how many times the query words appear in it, drop notes that score 0, and return the best first (ties broken by slug). Print slug (score) lines for three queries.
words = re.findall(r"[a-z]+", body.lower()); score = sum(words.count(w) for w in query words). Sort with key=lambda h: (-h[1], h[0]).
import re
NOTES = {
"python-tips": "# Python tips\nUse enumerate when you need the index.\nUse a dict for fast lookups.",
"mcp-basics": "# MCP basics\nServers expose tools, resources and prompts.\nClients call them over JSON-RPC.",
"testing": "# Testing\nWrite a failing test first.\nKeep tests fast and independent.",
}
def search(query, limit=2):
terms = re.findall(r"[a-z]+", query.lower())
hits = []
for slug, body in NOTES.items():
words = re.findall(r"[a-z]+", body.lower())
score = sum(words.count(t) for t in terms)
if score:
hits.append((slug, score))
hits.sort(key=lambda h: (-h[1], h[0]))
return hits[:limit]
for q in ["tests first", "use tools", "kubernetes"]:
hits = search(q)
print(f"{q}: " + (", ".join(f"{s} ({n})" for s, n in hits) or "no matches"))
Run your code to check this step…
Prompt templates
Publish one prompt, summarize_note, with a required note_id argument. get_prompt returns a single user message whose text is Summarise this note in two bullet points:, a blank line, then the note. A missing note_id raises ValueError("note_id is required").
if not arguments.get("note_id"): raise ValueError(...). Build the text with an f-string and \n\n between the instruction and NOTES[note_id].
import json
NOTES = {
"python-tips": "# Python tips\nUse enumerate when you need the index.\nUse a dict for fast lookups.",
"mcp-basics": "# MCP basics\nServers expose tools, resources and prompts.\nClients call them over JSON-RPC.",
}
PROMPTS = [{"name": "summarize_note", "description": "Summarise one note.",
"arguments": [{"name": "note_id", "required": True}]}]
def get_prompt(name, arguments):
note_id = arguments.get("note_id")
if not note_id:
raise ValueError("note_id is required")
text = f"Summarise this note in two bullet points:\n\n{NOTES[note_id]}"
return {"messages": [{"role": "user", "content": {"type": "text", "text": text}}]}
print(json.dumps(PROMPTS))
msg = get_prompt("summarize_note", {"note_id": "python-tips"})["messages"][0]
print(msg["role"])
print(msg["content"]["text"])
try:
get_prompt("summarize_note", {})
except ValueError as exc:
print(f"error: {exc}")
Run your code to check this step…
One server, one dispatcher
Route everything through serve(msg): resources/list, resources/read, tools/call for search (results as one text block, one hit per line) and prompts/get. Unknown URIs return error -32002 Resource not found; a prompt with missing arguments returns -32602. Print each reply.
Branch on msg["method"]. For resources/read check the slug is in NOTES before reading. For search, "\n".join(f"{s} ({n})" for s, n in hits) makes the text block.
import json
import re
NOTES = {
"python-tips": "# Python tips\nUse enumerate when you need the index.\nUse a dict for fast lookups.",
"mcp-basics": "# MCP basics\nServers expose tools, resources and prompts.\nClients call them over JSON-RPC.",
}
def search(query, limit=2):
terms = re.findall(r"[a-z]+", query.lower())
hits = []
for slug, body in NOTES.items():
score = sum(re.findall(r"[a-z]+", body.lower()).count(t) for t in terms)
if score:
hits.append((slug, score))
hits.sort(key=lambda h: (-h[1], h[0]))
return hits[:limit]
def ok(i, result):
return {"jsonrpc": "2.0", "id": i, "result": result}
def err(i, code, message):
return {"jsonrpc": "2.0", "id": i, "error": {"code": code, "message": message}}
def serve(msg):
i, method, params = msg["id"], msg["method"], msg.get("params", {})
if method == "resources/list":
return ok(i, {"resources": [{"uri": f"notes://{s}", "name": b.splitlines()[0].lstrip("# "),
"mimeType": "text/markdown"} for s, b in NOTES.items()]})
if method == "resources/read":
slug = params["uri"].removeprefix("notes://")
if slug not in NOTES:
return err(i, -32002, "Resource not found")
return ok(i, {"contents": [{"uri": params["uri"], "mimeType": "text/markdown", "text": NOTES[slug]}]})
if method == "tools/call" and params.get("name") == "search":
hits = search(params["arguments"]["query"])
text = "\n".join(f"{s} ({n})" for s, n in hits) or "no matches"
return ok(i, {"content": [{"type": "text", "text": text}], "isError": False})
if method == "prompts/get":
note_id = params.get("arguments", {}).get("note_id")
if not note_id:
return err(i, -32602, "note_id is required")
text = f"Summarise this note in two bullet points:\n\n{NOTES[note_id]}"
return ok(i, {"messages": [{"role": "user", "content": {"type": "text", "text": text}}]})
return err(i, -32601, f"Method not found: {method}")
session = [
{"jsonrpc": "2.0", "id": 1, "method": "resources/list"},
{"jsonrpc": "2.0", "id": 2, "method": "resources/read", "params": {"uri": "notes://nope"}},
{"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "search", "arguments": {"query": "use a dict"}}},
{"jsonrpc": "2.0", "id": 4, "method": "prompts/get", "params": {"name": "summarize_note", "arguments": {}}},
]
for msg in session:
print(json.dumps(serve(msg)))
Run your code to check this step…
Project shipped!
You just built a complete, working knowledge base mcp server: the same architecture running in production software. Pick your next build →
Take it further
Point this at a real folder of Markdown files and your assistant can search and summarise your own notes. Nothing below is graded: it runs on your own machine, not in this tab.
Stretch goals
- Load every
.mdfile from a folder withpathlib.Path.globinstead of a hard-coded dict. - Replace word counts with TF-IDF scoring (the Mini Search Engine project builds it) so common words matter less.
- Add an
add_notetool, and think hard about whether an assistant should be allowed to write files. - Send
notifications/resources/list_changedwhen a file in the folder changes.
notes_server.py: resources, a tool and a prompt with FastMCP
# pip install "mcp[cli]"
from pathlib import Path
from mcp.server.fastmcp import FastMCP
NOTES_DIR = Path("notes") # a folder of .md files
mcp = FastMCP("notes")
@mcp.resource("notes://{slug}")
def read_note(slug: str) -> str:
"""Read one note by its file name (without .md)."""
return (NOTES_DIR / f"{slug}.md").read_text()
@mcp.tool()
def search(query: str, limit: int = 3) -> str:
"""Find the notes that mention the query words most often."""
terms = query.lower().split()
scored = []
for path in NOTES_DIR.glob("*.md"):
words = path.read_text().lower().split()
score = sum(words.count(t) for t in terms)
if score:
scored.append((score, path.stem))
scored.sort(reverse=True)
return "\n".join(f"{slug} ({score})" for score, slug in scored[:limit]) or "no matches"
@mcp.prompt()
def summarize_note(note_id: str) -> str:
"""Summarise one note in two bullet points."""
return "Summarise this note in two bullet points:\n\n" + read_note(note_id)
if __name__ == "__main__":
mcp.run()
Ask an AI to review your build
I built an MCP server that exposes Markdown notes as resources (notes://slug), a ranked search tool and a summarize_note prompt template. Here is my code: [paste it]. Review the design: which of these should be resources versus tools, what happens with path traversal like notes://../secrets, and how should search scale to ten thousand notes?