MCP Server Tutorial
Python + FastAPI + Pydantic
How to Create MCP Server in 2026
Who this guide is for
If you're new to MCP and want a minimal, production-ready Python example, this walkthrough is for you. In ~20 minutes you'll stand up an MCP server, expose one simple tool, validate inputs/outputs with types, run it locally, and package it for reuse.
Prerequisites
- Python 3.11+
- macOS/Linux/WSL (Windows works with minor path tweaks)
gitand a terminal
1) Create a clean project & virtual environment
mkdir mcp-hello && cd mcp-hello
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
python -m pip install --upgrade pip2) Add the minimal dependencies
We'll use FastAPI for a lightweight HTTP server and Pydantic for contracts (schemas).
pip install fastapi uvicorn pydantic[dotenv]3) Define your MCP tool contracts (I/O typing)
Create models.py:
from pydantic import BaseModel, Field
from typing import Literal, Optional
class ToolSpec(BaseModel):
name: str
description: str
class ListToolsResponse(BaseModel):
tools: list[ToolSpec]
class AddArgs(BaseModel):
a: float = Field(..., description="First number")
b: float = Field(..., description="Second number")
class AddResult(BaseModel):
sum: float
class ToolCall(BaseModel):
request_id: str
tool: Literal["math.add"]
args: AddArgs
deadline_ms: Optional[int] = 2000
class ToolResponse(BaseModel):
request_id: str
status: Literal["ok","error"]
result: Optional[AddResult] = None
error: Optional[str] = None
latency_ms: Optional[int] = None4) Implement a minimal MCP server (simple tool)
Create main.py:
import time
from fastapi import FastAPI, HTTPException
from models import (
ToolSpec, ListToolsResponse, ToolCall, ToolResponse, AddResult
)
app = FastAPI(title="mcp-hello")
@app.get("/tools", response_model=ListToolsResponse)
def list_tools():
return ListToolsResponse(
tools=[ToolSpec(name="math.add", description="Add two numbers")]
)
@app.post("/tools/execute", response_model=ToolResponse)
def execute(call: ToolCall):
start = time.time()
if call.tool != "math.add":
raise HTTPException(status_code=400, detail="unknown tool")
s = call.args.a + call.args.b
return ToolResponse(
request_id=call.request_id,
status="ok",
result=AddResult(sum=s),
latency_ms=int((time.time() - start) * 1000),
)Run it
uvicorn main:app --reload --port 8787Test with curl:
curl -s http://localhost:8787/tools | jq
curl -s -X POST http://localhost:8787/tools/execute \
-H "content-type: application/json" \
-d '{"request_id":"r1","tool":"math.add","args":{"a":2.5,"b":4.5},"deadline_ms":1500}' | jqThis is your minimal MCP Python example—typed contracts + a simple tool.
5) Wire it into your vibe/agent client (request/response flow)
Typical client call (pseudo):
import requests, uuid
req = {
"request_id": str(uuid.uuid4()),
"tool": "math.add",
"args": {"a": 3, "b": 9},
"deadline_ms": 1500
}
res = requests.post("http://localhost:8787/tools/execute", json=req, timeout=2).json()
print(res["result"]["sum"]) # 126) Add guardrails: timeouts, errors, logging
Update main.py:
import logging
from fastapi import Request
logger = logging.getLogger("mcp")
logging.basicConfig(level=logging.INFO)
@app.middleware("http")
async def add_request_logging(request: Request, call_next):
rid = request.headers.get("x-request-id", "no-rid")
t0 = time.time()
response = await call_next(request)
logger.info("rid=%s path=%s status=%s latency_ms=%d",
rid, request.url.path, response.status_code, int((time.time()-t0)*1000))
return responseClient can set X-Request-Id so you can trace calls across systems.
7) (Optional) Basic authentication for local testing
pip install fastapi[all] python-multipartAdd simple API key check:
from fastapi.security.api_key import APIKeyHeader
from fastapi import Security
API_KEY = "dev-key-change-me"
api_key_header = APIKeyHeader(name="x-api-key", auto_error=True)
def verify_key(key: str = Security(api_key_header)):
if key != API_KEY:
raise HTTPException(status_code=401, detail="invalid api key")
@app.get("/tools", dependencies=[Security(verify_key)])
def list_tools_secure(): ...
@app.post("/tools/execute", dependencies=[Security(verify_key)])
def execute_secure(call: ToolCall): ...For production, prefer OIDC for humans and mTLS/OAuth for services (see our security guide).
8) Run & debug like a pro
Hot Reload
uvicorn main:app --reload
Pretty Errors
pip install rich then from rich import traceback; traceback.install()
Contracts First
Break a field type on purpose to confirm validation errors are descriptive.
9) Package for reuse
pyproject.toml
[project]
name = "mcp-hello"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = ["fastapi","uvicorn","pydantic"]
[project.scripts]
mcp-hello = "main:app"Build & publish (optional):
pip install build
python -m build10) Next steps
- Add more tools (e.g.,
files.read,repo.search) with tight schemas. - Stream large outputs (chunked responses) for better UX.
- Add rate limits, timeouts, and circuit breakers when calling downstream APIs.
- Containerize with a minimal base image and non-root user.
- Write contract tests in CI to prevent breaking changes.
FAQs
How to build an MCP server if I'm brand new to Python?
Follow this guide, then extend the math.add example. Keep contracts tiny and validated with Pydantic.
How to create MCP server endpoints for real tools?
Model each tool's inputs/outputs with Pydantic, implement the handler, and expose it under /tools/execute. Validate, log, and cap output size.
How to develop MCP server features safely?
Use feature flags, add structured logs with request_id, and write contract tests. Add auth (OIDC/mTLS) before exposing outside dev.
Key Takeaways
- Setup: Python venv + FastAPI + Pydantic for type-safe contracts
- Structure: Define schemas first, implement handlers, expose via REST endpoints
- Testing: Use curl/requests to test locally with hot reload
- Production: Add logging, auth, rate limits, and proper error handling
- Packaging: Use pyproject.toml for distribution and reuse