🛠️ MCP Implementation — Building Custom Servers & RAG Integration¶
Target: Staff/Principal Engineer | Focus: production-minded MCP server code, schema design, RAG integration. All code targets the official Python SDK v2 (
pip install "mcp>=2") and runs:python -m pytest tests/inai-engineering/mcp/exercises it.
30-second summary
A good MCP server is a thin, well-described adapter: precise types (they become the JSON Schemas the model reads), ToolError for failures the model should see, hard limits on output size, logging to stderr, and the real security boundary in the system behind it (read-only DB role, allowed root directory, scoped tokens). Test it in-process with Client(server), plus one end-to-end test over the real transport.
1. PROJECT STRUCTURE¶
ai-engineering/mcp/
├── requirements.txt # mcp>=2, pyjwt, psycopg2-binary, pytest
├── servers/
│ ├── calculator_server.py # Tutorial: tools, resources, prompts, structured output
│ ├── database_server.py # Read-only PostgreSQL + auth, rate limit, breaker
│ └── rag_server.py # Wraps ../rag/implementation
├── clients/
│ ├── python_client.py # CLI client on mcp.Client
│ └── claude_config.json # Example desktop-host config
├── common/
│ ├── auth.py # Bearer-JWT validation, per-request identity (ContextVar)
│ ├── rate_limiter.py # Per-client token bucket
│ └── circuit_breaker.py # Thread-safe CLOSED/OPEN/HALF_OPEN breaker
└── tests/
├── test_calculator.py # In-process + one stdio end-to-end test
└── test_rag_server.py # Mock LLM, throwaway vector store
Servers import common.*, so run everything from ai-engineering/mcp/ as modules (python -m servers.calculator_server).
2. IMPLEMENTATION — CALCULATOR MCP SERVER¶
2.1 Basic Server¶
# servers/calculator_server.py (abridged)
import sys
from mcp.server.mcpserver import MCPServer # v1: from mcp.server.fastmcp import FastMCP
from mcp.server.mcpserver.exceptions import ToolError
mcp = MCPServer("Calculator")
@mcp.tool()
def add(a: float, b: float) -> float:
"""Add two numbers together."""
return a + b
@mcp.tool()
def divide(a: float, b: float) -> float:
"""Divide a by b. Returns error if b is zero."""
if b == 0:
raise ToolError("Division by zero is not allowed")
return a / b
@mcp.resource("calculator://constants")
def get_constants() -> str:
"""Common mathematical constants."""
return "pi: 3.141592653589793\ne: 2.718281828459045"
@mcp.prompt()
def solve_equation(equation: str) -> str:
"""Create a prompt template for solving mathematical equations."""
return f"Solve the following equation step by step:\n\nEquation: {equation}"
if __name__ == "__main__":
print("Starting Calculator MCP Server (stdio)...", file=sys.stderr) # stderr, never stdout
mcp.run(transport="stdio")
What the SDK derives from this:
| Python | MCP |
|---|---|
| function name, docstring | tool name, description (what the model reads to choose a tool) |
a: float, b: float |
inputSchema with "type": "number", both required |
-> float |
outputSchema {"result": number}; results carry structuredContent: {"result": 8.0} plus a text block "8.0" |
raise ToolError(msg) |
result with isError: true and msg, so the model can recover |
| any other exception | result with isError: true and only "Error executing tool divide"; details stay in the server log |
2.2 Testing the Server¶
# Interactive, in the browser
npx @modelcontextprotocol/inspector python -m servers.calculator_server
# tests/test_calculator.py (abridged)
from mcp import Client
from servers.calculator_server import mcp as calculator
async def test_divide_by_zero_is_a_tool_error():
async with Client(calculator) as client: # in-process: no subprocess, no JSON framing
result = await client.call_tool("divide", {"a": 1, "b": 0})
assert result.is_error
assert "Division by zero" in result.content[0].text
async def test_structured_output():
async with Client(calculator) as client:
result = await client.call_tool("add", {"a": 5, "b": 3})
assert result.structured_content == {"result": 8.0}
Open the Client inside the test rather than in an async-generator fixture: it holds an anyio cancel scope that must be exited by the task that entered it, and pytest-asyncio may tear fixtures down in a different task ("Attempted to exit cancel scope in a different task").
3. IMPLEMENTATION — DATABASE MCP SERVER¶
3.1 Production-Ready Server¶
The full file is servers/database_server.py. The parts worth discussing in an interview:
mcp = MCPServer("DatabaseConnector")
rate_limiter = MCPRateLimiter(rate=10, burst=20)
db_circuit_breaker = CircuitBreaker(failure_threshold=5, reset_timeout=30, name="database")
def get_connection():
conn = psycopg2.connect(
DB_URL,
cursor_factory=RealDictCursor,
options=f"-c statement_timeout={QUERY_TIMEOUT_SECONDS * 1000}", # kill runaway queries
)
conn.set_session(readonly=True, autocommit=True) # read-only transactions
return conn
def execute_query(sql, params=None, max_rows=100):
# Friendly early rejection only. NOT a security boundary:
# "WITH x AS (DELETE ...)", side-effecting functions and pg_sleep all pass it.
if not sql.strip().upper().startswith(("SELECT", "WITH")):
raise ValueError("Only SELECT queries are allowed for security reasons.")
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(sql, params)
columns = [d[0] for d in cur.description] if cur.description else []
limit = min(max_rows, MAX_ROWS)
rows = cur.fetchmany(limit + 1) if cur.description else [] # +1: exact "truncated" flag
return {"columns": columns, "rows": [dict(r) for r in rows[:limit]],
"total_returned": min(len(rows), limit), "truncated": len(rows) > limit}
finally:
conn.close()
@mcp.tool()
@as_tool_errors # ValueError / AuthorizationError / rate limit → ToolError
@require_permission("database:read")
def query(sql: str, max_rows: int = 100) -> str:
"""Execute a read-only SQL query against the analytics database. ..."""
client_id = get_current_client_id() # tenant:user from the caller's identity
if not rate_limiter.check_rate_limit(client_id):
raise MCPRateLimitError(f"Rate limit exceeded. Try again in "
f"{rate_limiter.get_retry_after(client_id):.1f}s.")
try:
return db_circuit_breaker.call(_format_results, sql, int(min(max_rows, MAX_ROWS)))
except CircuitBreakerOpenError:
raise MCPRateLimitError("Database is temporarily unavailable. Please try again later.")
Design points:
- Decorator order matters.
@mcp.tool()must be outermost so the checked function is what gets registered;functools.wrapskeeps the signature so the schema is still generated fromsqlandmax_rows. - Read-only is enforced three times, strongest first: a database role with
SELECTon an allow-list of views (you create this; it's the real guarantee), a read-only session with a statement timeout, then the prefix check for a friendlier error. - Identity is per request.
common/auth.pykeeps the caller in aContextVar. Over stdio the identity comes from the environment (MCP_STDIO_PERMISSIONS=database:readgrants it locally); over HTTP it would come from the validated OAuth token. Without either, protected tools fail closed. - Schema listing uses the catalog.
pg_class.reltuplesgives an estimated row count without scanning every table;COUNT(*)per table would. - Health output is sanitised. Raw driver errors can include hostnames and credentials, so the health resource returns only the exception type.
4. IMPLEMENTATION — RAG MCP SERVER¶
4.1 Full RAG Integration¶
The full file is servers/rag_server.py; it wraps the pipeline in ai-engineering/rag/implementation/.
mcp = MCPServer("RAGPipeline")
# The RAG library prints progress to stdout; on stdio that would corrupt JSON-RPC.
with contextlib.redirect_stdout(sys.stderr):
pipeline = RAGPipeline(embedder=SentenceTransformerEmbedding(), store=ChromaVectorStore(),
llm=llm, loader=TextFileLoader())
@mcp.tool()
def rag_query(question: str, top_k: int = 5) -> str:
"""Complete RAG query: retrieve context and generate an answer. ..."""
with contextlib.redirect_stdout(sys.stderr):
result = pipeline.query(question, top_k=max(1, min(top_k, 10)))
return f"**Answer:** {result['answer']}" + format_sources(result.get("sources", []))
@mcp.tool()
def retrieve(question: str, top_k: int = 5) -> str:
"""Retrieve relevant document chunks WITHOUT generating an answer. ..."""
results = pipeline._retriever.retrieve(question, top_k=max(1, min(top_k, 10)))
return format_chunks(results) # SearchResult(.chunk.text, .chunk.metadata, .score)
@mcp.tool()
def index_document(file_path: str) -> str:
"""Index a file or directory into the RAG knowledge base. ..."""
real = os.path.realpath(file_path) # resolve "..", and symlinks first
if os.path.commonpath([real, RAG_ALLOWED_ROOT]) != RAG_ALLOWED_ROOT:
raise ToolError(f"Path '{file_path}' is outside the allowed root '{RAG_ALLOWED_ROOT}'.")
...
if __name__ == "__main__":
if MCP_TRANSPORT == "streamable-http":
mcp.run(transport="streamable-http", host="127.0.0.1", port=RAG_PORT) # /mcp
else:
mcp.run(transport="stdio")
Why these choices:
- Two granularities.
retrievereturns evidence the host's model can reason over and cite;rag_queryis a one-shot convenience. Many hosts already have a stronger model than your server's, so retrieval-first is usually the better default. - Path containment on
index_document. Without an allowed root, a prompt-injected model can index~/.ssh/id_ed25519and then retrieve it.realpathbefore the check defeats../and symlink tricks. - stdio by default, Streamable HTTP opt-in. Desktop hosts launch servers over stdio. The deprecated HTTP+SSE transport (
transport="sse") is not offered. - No temperature parameter on the tool. Sampling settings are an operator decision; exposing them lets the model (or an injected instruction) change them, and some current models reject non-default values anyway.
4.2 MCP + RAG Architecture¶
┌──────────────────────────────────────────────────────────────────┐
│ AI AGENT (Host) │
│ LLM decides: "I need the docs on chunking" │
│ → tools/call retrieve {"question": "How does chunking work?"} │
└─────────────────────────────┬────────────────────────────────────┘
│ MCP (JSON-RPC 2.0 over stdio or Streamable HTTP)
▼
┌──────────────────────────────────────────────────────────────────┐
│ MCP RAG SERVER │
│ │
│ tools: retrieve, rag_query, index_document │
│ resources: rag://status, rag://documents │
│ │ │
│ ┌──────────────┼──────────────┬───────────────┐ │
│ ▼ ▼ ▼ ▼ │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ Embedding │ │ Vector │ │ LLM │ │ Cache │ │
│ │ model │ │ store │ │ (LM Studio │ │ (TTL, keyed│ │
│ │ │ │ (Chroma) │ │ or mock) │ │ by index │ │
│ │ │ │ │ │ │ │ version) │ │
│ └────────────┘ └────────────┘ └────────────┘ └────────────┘ │
└──────────────────────────────────────────────────────────────────┘
The cache is a production addition, not in the demo code. Key it by normalised query, filters, tenant/ACL and index version.
4.3 Client Connection to RAG MCP Server¶
import asyncio
from mcp import Client, StdioServerParameters
async def query_rag(question: str) -> str:
params = StdioServerParameters(
command="python",
args=["-m", "servers.rag_server"],
env={"USE_MOCK_LLM": "true"}, # no LM Studio needed
)
async with Client(params) as client:
result = await client.call_tool("rag_query", {"question": question, "top_k": 5})
if result.is_error:
raise RuntimeError(result.content[0].text)
return result.content[0].text
print(asyncio.run(query_rag("What are the main components of RAG?")))
The child does not inherit your whole environment: the SDK passes a small safe default set (PATH, HOME, ...) and merges env over it (v2). Secrets the server needs must be passed explicitly, which is the point.
5. SCHEMA DISCOVERY & DESIGN PATTERNS¶
5.1 Tool Schema Design Guidelines¶
The model sees only names, descriptions and schemas; design them like an API for a capable but literal new colleague.
| Principle | Bad | Good |
|---|---|---|
| Task-shaped tools | http_get(url), run_sql(sql) for everything |
get_invoice(invoice_id), revenue_by_region(start, end) |
| Few tools | 80 tools mirroring every REST endpoint | A handful of high-level tools; tool overload hurts selection accuracy and burns context |
| Descriptions say when to use it | "A tool" | "Search the knowledge base. Prefer this over answer when you need to cite sources." |
| Typed, constrained parameters | args: str (a JSON blob) |
max_rows: Annotated[int, Field(ge=1, le=1000)] → minimum/maximum in the schema |
| Enums for closed sets | status: str |
status: Literal["open", "closed"] |
| Actionable errors | "Error" | "Rate limited; retry after 2 s or add a WHERE clause" |
| Bounded output | Whole table or file | Truncate, paginate (cursor), or return a resource link |
| Honest annotations | Missing | readOnlyHint, destructiveHint, idempotentHint, openWorldHint (hosts may use them for confirmation UI; they are hints, not enforcement) |
| Stable naming | Renaming tools casually | Names are an API: keep them stable, version behaviour behind them |
5.2 Resource URI Design¶
# Hierarchical URIs; {braces} make resource templates (RFC 6570)
database://schema/tables
database://schema/table/{table_name}
# System resources
system://config
system://metrics
# RAG-specific resources
rag://status
rag://documents
rag://document/{document_id}
Avoid putting the tenant in the URI (database://{tenant_id}/...) as the source of tenancy: the model can write any URI. Derive the tenant from the caller's token; if a tenant segment is in the URI, it must match the token.
5.3 Prompt Design¶
Prompts are user-invoked templates (often slash commands). They can return several messages and embed resources, which makes them a way to ship a workflow, not just a string.
from mcp.server.mcpserver.prompts.base import UserMessage
@mcp.prompt()
def debug_query(sql: str, error: str) -> list[UserMessage]:
"""Help debug a failed SQL query with context."""
return [UserMessage(
f"I ran this SQL and got an error. Explain what went wrong and give a fixed query.\n\n"
f"Query:\n{sql}\n\nError:\n{error}\n\n"
"Use the database://schema/tables resource to check table and column names."
)]
6. COMMON UTILITIES¶
6.1 Rate Limiter¶
common/rate_limiter.py: a token bucket per client key. Each key holds tokens (up to burst) refilled at rate per second; a request takes one token or is rejected. The lock makes check-and-take atomic across worker threads.
def check_rate_limit(self, client_id: str) -> bool:
with self._lock:
client = self._clients[client_id]
now = time.time()
client["tokens"] = min(self.burst, client["tokens"] + (now - client["last_refill"]) * self.rate)
client["last_refill"] = now
if client["tokens"] < 1:
return False
client["tokens"] -= 1
return True
Limits: in-memory and per replica (use Redis or the gateway when you scale out), and the per-client map grows without bound (evict idle keys).
6.2 Circuit Breaker¶
stateDiagram-v2
[*] --> CLOSED
CLOSED --> OPEN: N consecutive failures
OPEN --> HALF_OPEN: reset_timeout elapsed
HALF_OPEN --> CLOSED: probe succeeds
HALF_OPEN --> OPEN: probe fails
Three details that are easy to get wrong, all handled in the repo version:
- Consecutive means a success resets the failure count. Without that, five failures spread over a day trip the breaker.
- Exactly one probe in HALF_OPEN; concurrent callers are rejected until it finishes.
- A lock around state changes, because sync MCP tools run on worker threads. The lock is never held while the protected call runs, or one slow query would serialise every caller.
7. TESTING MCP SERVERS¶
# tests/test_rag_server.py (abridged)
import os, tempfile
import pytest
_TMP = tempfile.mkdtemp(prefix="rag-mcp-test-")
os.environ.update(USE_MOCK_LLM="true", RAG_ALLOWED_ROOT=_TMP,
PERSIST_DIRECTORY=os.path.join(_TMP, "vector_store")) # set BEFORE import
pytest.importorskip("chromadb")
from mcp import Client
from servers.rag_server import mcp as rag_server
async def test_index_outside_allowed_root_is_refused():
async with Client(rag_server) as client:
result = await client.call_tool("index_document", {"file_path": "/etc/passwd"})
assert result.is_error
assert "outside the allowed root" in result.content[0].text
What a solid MCP test suite covers:
| Layer | Test |
|---|---|
| Contract | list_tools names, input/output schemas, annotations (a schema change is an API change) |
| Behaviour | Happy paths, with structured_content checked |
| Errors | ToolError → is_error with a useful message; bad arguments → is_error; unexpected exceptions don't leak details |
| Security | Path containment, permission checks fail closed, tenant isolation |
| Transport | One end-to-end run over stdio (catches stray stdout prints) and, for HTTP servers, Origin/auth checks |
| Model-in-the-loop (evals) | Given realistic prompts, does a model pick the right tool with the right arguments? Run these on description changes |
Next: MCP Production Architecture → Security, deployment, tradeoffs