Model Context ProtocolMCP Python SDK 2.2 · LangChain 1.4 · Python 3.10+
Dashboard
0%
1
Curious builder0 XP earned · 300 to level 2
0 daysFinish a lesson to begin
Badge collection0 of 6 unlocked
27 small wins to finish your pathNext lesson →

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

An MCP server inside a deployed FastAPI app · from the Complete AI Security Course In 8 Hours · 413:06 to 418:45
Project files used on this pageThis lesson builds on a project from earlier lessons. The code below imports these files. Click a file to see its code, or follow the link to the lesson that wrote it. To run the code yourself, keep them in the same folder.
View the code here
shop.py
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)
client.py
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.

Exampleapp.py
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.

Example
uvicorn app:app --port 8200 --log-level warning &
sleep 3
curl -s localhost:8200/health
echo
python client.py
kill $!
  • One process served both, the FastAPI /health endpoint 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.
Localhost only by default
The app accepts requests addressed to localhost only, as protection against DNS rebinding attacks. Deployed behind a real hostname, every request is refused with 421 Invalid Host header until that hostname is allowed through the transport_security setting.

A standalone server vs mounting in FastAPI

StandaloneMounted in FastAPI
ProcessesTwoOne
Other HTTP routesNoYes, beside /mcp
Session managerStarted 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.
Watch out
A mounted MCP app's own lifespan never runs, so its session manager stays off. Start it yourself with async with mcp.session_manager.run() in the host app's lifespan, or the first request fails.
Try it yourself
  • Remove the lifespan=lifespan argument, restart, and run the client.
  • Move app.mount above @app.get("/health") and call /health.
  • Add a FastAPI endpoint that calls lookup_order through Client(mcp).

This is what real progress feels like.