MCP with FastAPI
Mounting is putting the MCP server inside an existing FastAPI app so one process serves both your own routes and the MCP endpoint.
Last updated: 29 Sep, 2026 · MCP 2.2
View the code here
from typing import Annotated, Literal
from pydantic import BaseModel, Field
from mcp.server import MCPServer
from mcp.server.mcpserver.prompts.base import AssistantMessage, Message, UserMessage
from mcp.server.mcpserver.exceptions import ResourceNotFoundError, ToolError
from mcp.types import ToolAnnotations
mcp = MCPServer("Shop support", log_level="WARNING")
ORDERS = {
"A17": {"item": "blue mug", "status": "shipped", "total": 12.50},
"B42": {"item": "desk lamp", "status": "processing", "total": 48.00},
}
ARTICLES = {
"Where is my order?": "orders",
"Changing an order": "orders",
"How refunds work": "refunds",
"Refunds for damaged items": "refunds",
"Resetting your password": "account",
}
class Order(BaseModel):
id: str
item: str
status: Literal["processing", "shipped", "delivered"]
total: float
@mcp.tool(annotations=ToolAnnotations(read_only_hint=True))
def lookup_order(order_id: str) -> Order:
"""Look up an order by its id."""
if order_id not in ORDERS:
raise ToolError(f"No order with id {order_id!r}. Order ids look like A17.")
return Order(id=order_id, **ORDERS[order_id])
@mcp.tool()
def search_help(
query: Annotated[str, Field(description="Words to look for in the help articles.")],
topic: Literal["orders", "refunds", "account"] | None = None,
limit: Annotated[int, Field(ge=1, le=5)] = 3,
) -> str:
"""Search the help centre and return matching article titles."""
found = [title for title, t in ARTICLES.items() if query.lower() in title.lower() and topic in (None, t)]
return "; ".join(found[:limit]) or "No articles found."
@mcp.tool(
title="Refund an order",
annotations=ToolAnnotations(read_only_hint=False, destructive_hint=False, idempotent_hint=False),
)
def refund_order(order_id: str, reason: str) -> str:
"""Refund the full total of an order to the customer's card."""
if order_id not in ORDERS:
raise ToolError(f"No order with id {order_id!r}.")
return f"Refunded {ORDERS[order_id]['total']:.2f} for order {order_id}: {reason}."
@mcp.resource("policy://refunds", mime_type="text/markdown")
def refund_policy() -> str:
"""The shop's refund policy."""
return "# Refunds\n\nFull refund within 30 days of delivery. Damaged items: refund or replacement."
@mcp.resource("orders://{order_id}", mime_type="application/json")
def order_record(order_id: str) -> Order:
"""The full record for one order."""
if order_id not in ORDERS:
raise ResourceNotFoundError(f"No order with id {order_id!r}.")
return Order(id=order_id, **ORDERS[order_id])
@mcp.prompt(title="Reply to a customer")
def reply_to_customer(ticket: str, tone: str = "friendly") -> list[Message]:
"""Draft a reply to a support ticket."""
return [
UserMessage(f"Write a {tone} reply to this support ticket:\n\n{ticket}"),
AssistantMessage("Hello, and thank you for getting in touch."),
]
if __name__ == "__main__":
mcp.run(transport="streamable-http", port=8200)
import asyncio
from mcp import Client
async def main():
async with Client("http://127.0.0.1:8200/mcp") as client:
print(client.protocol_version)
result = await client.call_tool("lookup_order", {"order_id": "B42"})
print(result.structured_content)
asyncio.run(main())
The AI security course shows this in production. Its research assistant is a FastAPI application, and while building it the team turned the main routes into MCP tools with FastMCP, served by the same app at localhost:8000/mcp. The MCP Inspector connects to that URL over HTTP and lists tools that search papers and answer questions, and Claude Desktop reaches the same server through npx mcp-remote. One application stays the web API for its own front end and is also an MCP server that anyone with an API token can add to a coding agent.
That app uses the standalone fastmcp package. This lesson mounts the SDK's own MCPServer inside FastAPI, which gives the same result: the MCP endpoint at /mcp next to your routes, in one process on one port.
from contextlib import asynccontextmanager
from fastapi import FastAPI
from shop import mcp
mcp_app = mcp.streamable_http_app()
@asynccontextmanager
async def lifespan(app: FastAPI):
async with mcp.session_manager.run():
yield
app = FastAPI(lifespan=lifespan)
@app.get("/health")
def health():
return {"status": "ok"}
app.mount("/", mcp_app)mcp.streamable_http_app() returns the MCP server as a web application, and app.mount("/", mcp_app) puts it inside FastAPI, so /mcp is the MCP endpoint and /health is still yours. The mount goes last, because a mount at / matches every path and FastAPI checks routes in order.
The lifespan line is the one people forget. The MCP app starts its session manager in its own lifespan, and a mounted app's lifespan never runs. The host app has to start it, with async with mcp.session_manager.run(). Without it, the first MCP request fails with a 500, and the server logs Task group is not initialized.
Stop the Streamable HTTP server first: this app serves on the same port, 8200, so client.py from the Streamable HTTP lesson works unchanged.
uvicorn app:app --port 8200 --log-level warning &
sleep 3
curl -s localhost:8200/health
echo
python client.py
kill $!{"status":"ok"}
2026-07-28
{'id': 'B42', 'item': 'desk lamp', 'status': 'processing', 'total': 48.0}- One process served both, the FastAPI
/healthendpoint and the MCP server at/mcp, on one port. - The same client as the Streamable HTTP lesson worked unchanged, because the MCP endpoint did not move.
transport_security setting.A standalone server vs mounting in FastAPI
| Standalone | Mounted in FastAPI | |
|---|---|---|
| Processes | Two | One |
| Other HTTP routes | No | Yes, beside /mcp |
| Session manager | Started by run() | You start it in the app lifespan |
When to mount in FastAPI
- A service that already runs FastAPI and needs MCP beside its routes.
- Sharing a port, a deployment and the middleware with your own routes.
- Adding /health or admin routes next to the MCP endpoint.
async with mcp.session_manager.run() in the host app's lifespan, or the first request fails.Related
- Previous: Streamable HTTP transport
- Next: MCP hosts
- Remove the
lifespan=lifespanargument, restart, and run the client. - Move
app.mountabove@app.get("/health")and call/health. - Add a FastAPI endpoint that calls
lookup_orderthroughClient(mcp).
This is what real progress feels like.