How to build MCP servers with FastMCP 3.x and mount them inside FastAPI.
Reference: The complete FastMCP documentation index is available at https://gofastmcp.com/llms.txt. Fetch it to discover all available pages, API references, and integration guides.
MCP (Model Context Protocol) is a standard protocol for LLMs to call external tools, read resources, and execute code. Clients include Claude Code, Claude Desktop, Cursor, ChatGPT, and Gemini CLI.
FastMCP is the Python framework for building MCP servers. It handles:
- Tool schema generation from Python type hints
- stdio transport (local — Claude Code on same machine)
- HTTP transport (remote — agents over the network)
- Parameter validation (Pydantic)
- Auth, middleware, pagination
You write Python functions. FastMCP exposes them to LLMs.
pip install fastmcpFastMCP does NOT include FastAPI — install separately if mounting:
pip install fastmcp fastapi uvicornfrom fastmcp import FastMCP
mcp = FastMCP("My Server")
@mcp.tool
def add(a: int, b: int) -> int:
"""Adds two integer numbers together."""
return a + bFastMCP auto-generates:
- Tool name from the function name (
add) - Description from the docstring
- Input schema from type annotations
- Output schema from the return type annotation
@mcp.tool
async def fetch_user(user_id: str) -> dict:
"""Fetch a user from the database."""
return await db.fetchrow("SELECT * FROM users WHERE id = $1", user_id)Sync tools run in a threadpool automatically — they don't block the event loop.
from typing import Annotated
from pydantic import Field
@mcp.tool
def search(
query: Annotated[str, Field(description="Search query")],
limit: Annotated[int, Field(description="Max results", ge=1, le=100)] = 10,
) -> list[dict]:
"""Search the database."""
...@mcp.tool(annotations={
"readOnlyHint": True, # tool doesn't modify state
"destructiveHint": False, # not destructive
"idempotentHint": True, # repeatable with same result
"openWorldHint": False, # only interacts with internal data
})
def get_user(user_id: str) -> dict:
"""Retrieve user information."""
...Clients like Claude and ChatGPT use these hints to skip confirmation prompts for read-only tools.
Inject runtime values (user IDs, DB sessions, credentials) via Depends().
These are excluded from the tool schema — the LLM never sees them:
from fastmcp.dependencies import Depends
def get_current_user() -> str:
return "user_123" # resolved at runtime
@mcp.tool
def my_data(user_id: str = Depends(get_current_user)) -> dict:
"""Get data for the current user."""
return {"user": user_id}Raise any exception. FastMCP converts it to an MCP error response:
from fastmcp.exceptions import ToolError
@mcp.tool
def divide(a: float, b: float) -> float:
"""Divide a by b."""
if b == 0:
raise ToolError("Division by zero is not allowed.")
return a / bToolError messages are always sent to the client. Other exception
details can be masked in production:
mcp = FastMCP("Secure Server", mask_error_details=True)if __name__ == "__main__":
mcp.run() # defaults to stdioClaude Code connects via stdin/stdout. No network.
Direct (simple):
if __name__ == "__main__":
mcp.run(transport="http", host="0.0.0.0", port=8000)Server accessible at http://localhost:8000/mcp.
ASGI app (production — more control):
app = mcp.http_app()uvicorn server:app --host 0.0.0.0 --port 8000This is the main pattern for our project — serve REST API + MCP from the same application:
from fastapi import FastAPI
from fastmcp import FastMCP
# Your MCP server
mcp = FastMCP("Analytics Tools")
@mcp.tool
def analyze_data(query: str) -> dict:
"""Analyze data."""
return {"result": "..."}
# Create MCP's ASGI app
mcp_app = mcp.http_app(path="/")
# Create FastAPI app with MCP's lifespan (REQUIRED)
app = FastAPI(lifespan=mcp_app.lifespan)
@app.get("/api/status")
def status():
return {"status": "ok"}
# Mount MCP at /mcp
app.mount("/mcp", mcp_app)Result:
- REST API at
http://localhost:8000/api/status - MCP endpoint at
http://localhost:8000/mcp
# ✅ Correct — session manager initializes
app = FastAPI(lifespan=mcp_app.lifespan)
app.mount("/mcp", mcp_app)
# ❌ Wrong — session manager won't initialize, requests fail
app = FastAPI()
app.mount("/mcp", mcp.http_app(path="/"))When your FastAPI app already has a lifespan (for DB connections), use
combine_lifespans to run both:
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastmcp import FastMCP
from fastmcp.utilities.lifespan import combine_lifespans
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
# Your database lifespan
@asynccontextmanager
async def db_lifespan(app: FastAPI):
engine = create_async_engine("postgresql+asyncpg://...")
app.state.engine = engine
app.state.session_factory = async_sessionmaker(engine, expire_on_commit=False)
yield
await engine.dispose()
# Create MCP server
mcp = FastMCP("My Server")
@mcp.tool
async def list_users() -> list[dict]:
"""List all users from the database."""
# Access the session factory via the MCP server's state
# or pass it through dependency injection
...
# Create MCP ASGI app
mcp_app = mcp.http_app(path="/")
# Combine both lifespans — DB + MCP
app = FastAPI(lifespan=combine_lifespans(db_lifespan, mcp_app.lifespan))
app.mount("/mcp", mcp_app)combine_lifespans enters lifespans in order and exits in reverse order.
Tools can access MCP features through the Context object:
from fastmcp import FastMCP, Context
mcp = FastMCP("My Server")
@mcp.tool
async def process_large_file(file_path: str, ctx: Context) -> str:
"""Process a large file with progress updates."""
await ctx.info(f"Starting to process {file_path}")
lines = read_file(file_path)
total = len(lines)
for i, line in enumerate(lines):
await ctx.report_progress(progress=i, total=total)
await ctx.info("Processing complete")
return f"Processed {total} lines"Context provides:
- Logging:
ctx.debug(),ctx.info(),ctx.warning(),ctx.error() - Progress:
ctx.report_progress(progress, total) - Resource access:
ctx.read_resource(uri) - LLM sampling:
ctx.sample("Summarize this: ...") - Request info:
ctx.request_id,ctx.client_id
MCP tools run inside the same process as FastAPI. To access app state (database sessions, config, etc.), use a closure or module-level reference:
from fastapi import FastAPI
from fastmcp import FastMCP
mcp = FastMCP("My Server")
# Store a reference to app state that gets set during lifespan
_app_state: dict = {}
@mcp.tool
async def list_users() -> list[dict]:
"""List all users."""
session_factory = _app_state["session_factory"]
async with session_factory() as session:
from opensilk.models import User
result = await session.execute(select(User))
users = result.scalars().all()
return [{"id": str(u.id), "email": u.email} for u in users]
@asynccontextmanager
async def combined_lifespan(app: FastAPI):
# Set up DB
engine = create_async_engine(DATABASE_URL)
factory = async_sessionmaker(engine, expire_on_commit=False)
_app_state["session_factory"] = factory
_app_state["engine"] = engine
yield
await engine.dispose()
# Wire up
mcp_app = mcp.http_app(path="/")
app = FastAPI(lifespan=combine_lifespans(combined_lifespan, mcp_app.lifespan))
app.mount("/mcp", mcp_app)FastMCP can auto-convert your FastAPI endpoints into MCP tools:
mcp = FastMCP.from_fastapi(app=app)This uses OpenAPIProvider under the hood — it reads your OpenAPI spec
and exposes each endpoint as a tool.
Warning: Auto-converted tools have poor LLM performance compared to purpose-built tools. Use this for prototyping, not production:
"LLMs achieve significantly better performance with well-designed and curated MCP servers than with auto-converted OpenAPI servers." — FastMCP docs
For production, write purpose-built @mcp.tool functions with clear
docstrings and simple parameters.
When running multiple instances behind a load balancer, enable stateless mode — each request is self-contained, no sticky sessions needed:
app = mcp.http_app(stateless_http=True)Or via environment variable:
FASTMCP_STATELESS_HTTP=true uvicorn app:app --workers 4Sticky sessions don't work with MCP clients (Cursor, Claude Code don't forward cookies). Stateless mode is the only reliable scaling strategy.
SSE streaming requires specific nginx settings:
server {
listen 443 ssl;
server_name mcp.example.com;
ssl_certificate /etc/letsencrypt/live/mcp.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/mcp.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# CRITICAL for SSE streaming
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
}
}proxy_buffering off is the most important setting. Without it,
nginx buffers the entire SSE stream and delivers it only when the
connection closes — breaking real-time communication.
[Unit]
Description=OpenSilk MCP Server
After=network.target
[Service]
User=www-data
Group=www-data
WorkingDirectory=/opt/opensilk
ExecStart=/opt/opensilk/.venv/bin/uvicorn opensilk.app:app --host 127.0.0.1 --port 8000
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.targetfrom starlette.responses import JSONResponse
@mcp.custom_route("/health", methods=["GET"])
async def health_check(request):
return JSONResponse({"status": "healthy"})Custom routes are never protected by auth — they're for load balancers and monitoring.
from fastmcp.server.auth import BearerTokenAuth
auth = BearerTokenAuth(token="your-secret-token")
mcp = FastMCP("Protected Server", auth=auth)FastMCP supports GitHub, Google, Auth0, Supabase, Discord, and more. See the Authentication docs for full configuration.
Use the in-memory client to test without network:
from fastmcp import FastMCP
from fastmcp.client import Client
import asyncio
mcp = FastMCP("Test Server")
@mcp.tool
def add(a: int, b: int) -> int:
"""Add two numbers."""
return a + b
async def test():
async with Client(mcp) as client:
# List available tools
tools = await client.list_tools()
print([t.name for t in tools]) # ['add']
# Call a tool
result = await client.call_tool("add", {"a": 1, "b": 2})
print(result.data) # 3
asyncio.run(test())import os
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastmcp import FastMCP
from fastmcp.utilities.lifespan import combine_lifespans
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
DATABASE_URL = os.environ["DATABASE_URL"]
# --- Database setup ---
_state: dict = {}
@asynccontextmanager
async def db_lifespan(app: FastAPI):
engine = create_async_engine(DATABASE_URL, pool_pre_ping=True)
_state["factory"] = async_sessionmaker(engine, expire_on_commit=False)
yield
await engine.dispose()
# --- MCP server ---
mcp = FastMCP("OpenSilk")
@mcp.tool(annotations={"readOnlyHint": True})
async def list_users() -> list[dict]:
"""List all registered users."""
factory = _state["factory"]
async with factory() as session:
result = await session.execute(text("SELECT id, email, name FROM users"))
return [dict(row) for row in result.fetchall()]
@mcp.tool
async def create_user(email: str, name: str) -> dict:
"""Create a new user."""
factory = _state["factory"]
async with factory() as session:
result = await session.execute(
text("INSERT INTO users (email, name) VALUES (:email, :name) RETURNING id, email, name"),
{"email": email, "name": name},
)
await session.commit()
return dict(result.fetchone())
# --- Wire up ---
mcp_app = mcp.http_app(path="/")
app = FastAPI(lifespan=combine_lifespans(db_lifespan, mcp_app.lifespan))
app.mount("/mcp", mcp_app)
@app.get("/health")
def health():
return {"status": "ok"}Run:
DATABASE_URL=postgresql+asyncpg://opensilk:opensilk@localhost:5432/opensilk \
uvicorn server:app --host 0.0.0.0 --port 8000Result:
- REST health check at
http://localhost:8000/health - MCP endpoint at
http://localhost:8000/mcp - Tools:
list_users,create_user
Ties together the PostgreSQL guide and this guide. Same User model, same password hashing, same database — but now exposed as both a REST API (for web clients) and MCP tools (for AI agents).
The LLM can register users, log in, and list users — all through MCP. The REST API serves the same data to browsers and scripts.
import os
import uuid
from contextlib import asynccontextmanager
from datetime import datetime
import bcrypt
from fastapi import FastAPI, HTTPException, APIRouter
from fastmcp import FastMCP
from fastmcp.utilities.lifespan import combine_lifespans
from pydantic import BaseModel, ConfigDict, EmailStr
from sqlalchemy import String, select, text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
DATABASE_URL = os.environ["DATABASE_URL"]
# ── Database layer ───────────────────────────────────────────────
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid7)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
name: Mapped[str] = mapped_column(String(100))
password_hash: Mapped[str] = mapped_column(String(255))
created_at: Mapped[datetime] = mapped_column(server_default=text("now()"))
# ── Schemas (for REST API) ───────────────────────────────────────
class UserCreate(BaseModel):
email: EmailStr
name: str
password: str
class UserLogin(BaseModel):
email: EmailStr
password: str
class UserResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
email: str
name: str
created_at: datetime
# ── Auth helpers ─────────────────────────────────────────────────
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
def verify_password(password: str, hashed: str) -> bool:
return bcrypt.checkpw(password.encode(), hashed.encode())
# ── App state (shared between FastAPI and MCP) ───────────────────
_state: dict = {}
@asynccontextmanager
async def db_lifespan(app: FastAPI):
engine = create_async_engine(DATABASE_URL, pool_pre_ping=True)
_state["engine"] = engine
_state["factory"] = async_sessionmaker(engine, expire_on_commit=False)
yield
await engine.dispose()
# ── REST API routes (for browsers, scripts, curl) ────────────────
rest = APIRouter()
@rest.post("/register", response_model=UserResponse, status_code=201)
async def register(body: UserCreate):
factory = _state["factory"]
async with factory() as session:
user = User(
email=body.email,
name=body.name,
password_hash=hash_password(body.password),
)
session.add(user)
try:
await session.flush()
except IntegrityError:
raise HTTPException(409, "Email already registered")
await session.commit()
return user
@rest.post("/login", response_model=UserResponse)
async def login(body: UserLogin):
factory = _state["factory"]
async with factory() as session:
stmt = select(User).where(User.email == body.email)
user = (await session.execute(stmt)).scalar_one_or_none()
if user is None or not verify_password(body.password, user.password_hash):
raise HTTPException(401, "Invalid email or password")
return user
# ── MCP tools (for AI agents — Claude Code, Cursor, etc.) ────────
mcp = FastMCP("User Service")
@mcp.tool
async def mcp_register(email: str, name: str, password: str) -> dict:
"""Register a new user account.
Args:
email: User's email address (must be unique).
name: Display name for the user.
password: Plaintext password (will be hashed).
Returns:
User object with id, email, name, and created_at.
Returns {"error": "Email already registered"} on conflict.
"""
factory = _state["factory"]
async with factory() as session:
user = User(
email=email,
name=name,
password_hash=hash_password(password),
)
session.add(user)
try:
await session.flush()
except IntegrityError:
return {"error": "Email already registered"}
await session.commit()
return {
"id": str(user.id),
"email": user.email,
"name": user.name,
"created_at": user.created_at.isoformat(),
}
@mcp.tool
async def mcp_login(email: str, password: str) -> dict:
"""Verify user credentials and return user info.
Args:
email: User's email address.
password: Plaintext password to verify.
Returns:
User object on success, {"error": "Invalid credentials"} on failure.
"""
factory = _state["factory"]
async with factory() as session:
stmt = select(User).where(User.email == email)
user = (await session.execute(stmt)).scalar_one_or_none()
if user is None or not verify_password(password, user.password_hash):
return {"error": "Invalid credentials"}
return {
"id": str(user.id),
"email": user.email,
"name": user.name,
"created_at": user.created_at.isoformat(),
}
@mcp.tool(annotations={"readOnlyHint": True})
async def mcp_list_users() -> list[dict]:
"""List all registered users (id, email, name only — no password hashes).
Returns:
List of user objects.
"""
factory = _state["factory"]
async with factory() as session:
stmt = select(User).order_by(User.created_at.desc())
users = (await session.execute(stmt)).scalars().all()
return [
{"id": str(u.id), "email": u.email, "name": u.name}
for u in users
]
# ── Wire everything together ─────────────────────────────────────
mcp_app = mcp.http_app(path="/")
app = FastAPI(lifespan=combine_lifespans(db_lifespan, mcp_app.lifespan))
app.include_router(rest)
app.mount("/mcp", mcp_app)
@app.get("/health")
def health():
return {"status": "ok"}An AI agent connecting to http://localhost:8000/mcp sees three tools:
mcp_register(email, name, password) → create account
mcp_login(email, password) → verify credentials
mcp_list_users() → list all users (read-only)
It does NOT see:
password_hash(excluded from all return values)UserSQLAlchemy model internals- REST routes (
/register,/login) - Database connection details
A web client hitting the REST API:
POST /register {"email": "...", "name": "...", "password": "..."} → 201
POST /login {"email": "...", "password": "..."} → 200
GET /health → 200
| Aspect | REST routes | MCP tools |
|---|---|---|
| Audience | Browsers, curl, scripts | AI agents (Claude, Cursor) |
| Naming | RESTful verbs (/register) |
Descriptive (mcp_register) |
| Errors | HTTPException (409, 401) | Return {"error": "..."} dict |
| Response | Pydantic model (UserResponse) |
Plain dict (no password_hash) |
| Schema | OpenAPI auto-generated | FastMCP auto-generated from type hints |
| Annotations | None | readOnlyHint, destructiveHint |
Why return dicts from MCP tools instead of raising exceptions? LLMs
handle structured error responses better than HTTP error codes. An AI
agent can read {"error": "Email already registered"} and explain it
to the user. An HTTP 409 is opaque.
# 1. Start Postgres
docker compose up -d
# 2. Run migrations
alembic upgrade head
# 3. Start the server
DATABASE_URL=postgresql+asyncpg://opensilk:opensilk@localhost:5432/opensilk \
uvicorn server:app --host 0.0.0.0 --port 8000
# 4. Test MCP endpoint
curl http://localhost:8000/mcp
# 5. Test REST endpoint
curl -X POST http://localhost:8000/register \
-H "Content-Type: application/json" \
-d '{"email":"alice@test.com","name":"Alice","password":"secret"}'
# 6. Connect from Claude Desktop
# Add to claude_desktop_config.json:
# {
# "mcpServers": {
# "user-service": {
# "url": "http://localhost:8000/mcp"
# }
# }
# }Now Claude Code can register users, log in, and list users — by calling MCP tools that execute against your Postgres database.
| Resource | URL |
|---|---|
| LLM-friendly doc index | https://gofastmcp.com/llms.txt |
| FastAPI integration | https://gofastmcp.com/integrations/fastapi |
| Tools docs | https://gofastmcp.com/servers/tools |
| HTTP deployment | https://gofastmcp.com/deployment/http |
| Auth | https://gofastmcp.com/servers/auth/authentication |
| Middleware | https://gofastmcp.com/servers/middleware |
| Context | https://gofastmcp.com/servers/context |
| Dependency injection | https://gofastmcp.com/servers/dependency-injection |