MCP inside FastAPI
|
This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
This page was written against the 2026-07-28 revision of
the specification, the Python mcp SDK 2.2.0, fastmcp 4.0.10, fastapi-mcp 0.4.0 and FastAPI 0.141.1 on Python 3.12,
as checked on 2026-09-29 (see the Model Context Protocol baseline). Every listing was run
against those packages, and the failure described in the first section was reproduced, not assumed. The server being
mounted is the docs-assistant from Python MCP Servers.
A team that already runs a FastAPI service usually does not want a second deployable just to speak MCP. The MCP endpoint is an ordinary ASGI application, so it can live in the same process as the REST API and share its configuration, its authentication and its business logic. Doing it correctly takes three decisions: how the MCP application is mounted, whether its tools are written or generated, and what is shared with the REST side.
The dotted edge is the one most often forgotten: the MCP application needs its session manager running, and a mounted application cannot start it on its own.
| Option | What it is | Choose it when |
|---|---|---|
1. Mount the official SDK app |
|
You use |
2. Mount a FastMCP app |
|
You use FastMCP for its extra features, or already run FastMCP servers. |
3. Generate from OpenAPI |
|
You are bootstrapping or prototyping. Not the recommended end state. |
4. Share auth and logic |
One verifier, one service module and one lifespan, used by REST routes and MCP tools alike. |
Always, whichever of the first three you picked. |
Option 1: mount streamable_http_app()
MCPServer.streamable_http_app() returns a Starlette application with one route, /mcp, and a lifespan that starts
the SDK’s session manager, the object that owns the background work of every live request. FastAPI is a Starlette
application, so mounting works. The trap is in that last sentence of the previous paragraph: the lifespan that starts the
session manager belongs to the sub-application, and Starlette runs the lifespan of the top-level application only.
The failing version
This is the obvious way to write it, and it starts without complaint:
"""REST + MCP in one FastAPI process -- the FAILING version: the mounted app's lifespan never runs."""
from fastapi import FastAPI
import store
from server import mcp
app = FastAPI(title="docs-assistant") # no lifespan: nothing starts mcp.session_manager
@app.get("/api/components")
def components() -> list[str]:
return store.COMPONENTS
app.mount("/", mcp.streamable_http_app())
Started with uvicorn broken_app:app, it logs "Application startup complete" and serves the REST route normally. The
first MCP request is what fails:
INFO: 127.0.0.1:64870 - "GET /api/components HTTP/1.1" 200 OK
INFO: 127.0.0.1:64871 - "POST /mcp HTTP/1.1" 500 Internal Server Error
ERROR: Exception in ASGI application
...
File ".../mcp/server/streamable_http_manager.py", line 185, in _handle_request
raise RuntimeError("Task group is not initialized. Make sure to use run().")
RuntimeError: Task group is not initialized. Make sure to use run().
The symptom is a bare 500 Internal Server Error with a plain-text body, on every MCP request, while the health check
and the REST routes stay green. That is what makes it dangerous: a deployment that only probes /api/health reports
success. One more tell-tale sits in the startup log. A correctly wired server prints a StreamableHTTP session manager
started line during startup; the failing one prints nothing between "Waiting for application startup" and "Application
startup complete".
The fixed version
The host application must enter session_manager.run() in its own lifespan. session_manager exists only after
streamable_http_app() has been called, which is why the app is built at import time and the manager is touched inside
the lifespan:
"""REST + MCP in one FastAPI process -- the fixed version: the host app enters session_manager.run()."""
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from fastapi import FastAPI
import store
from server import mcp
mcp_app = mcp.streamable_http_app() # builds mcp.session_manager as a side effect
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
async with mcp.session_manager.run(): # the line everyone forgets
yield
app = FastAPI(title="docs-assistant", lifespan=lifespan)
@app.get("/api/components")
def components() -> list[str]:
return store.COMPONENTS
app.mount("/", mcp_app) # after the REST routes: Mount("/") matches every path
With this file the MCP endpoint is http://127.0.0.1:8000/mcp and the REST route sits beside it. The whole shared
contract script passed against it, all six steps including the multi-round-trip elicitation (see
Testing & Debugging). See the official
Add to an existing app guide, which calls the lifespan line "the line
everyone forgets".
If your application already has a lifespan for database pools or caches, enter both in one function:
async with mcp.session_manager.run(): … inside your existing lifespan, or an AsyncExitStack when you mount several
MCP servers, each of which has its own session manager. Three more details of mounting are worth knowing:
-
Order.
Mount("/")matches every path, so it must come after your REST routes; a route declared after it is unreachable. -
Path. The endpoint path is the mount prefix plus
streamable_http_path. Mounted at/docs-mcpwithstreamable_http_app(streamable_http_path="/"), the endpoint answered on/docs-mcp/(the version without the trailing slash returned307, so configure clients with the slash) and/docs-mcp/mcpreturned404. -
Host allowlist. The app answers only localhost
Hostheaders by default. A request withHost: example.comgot421 Invalid Host header. Behind a real hostname passtransport_security=with an allowlist of the names you serve; this is the same protection against DNS rebinding described on Transports.
Option 2: FastMCP http_app() and combine_lifespans
The standalone FastMCP 4 has the same requirement with a different API. mcp.http_app(path="/mcp") returns the ASGI
application and exposes its lifespan as mcp_app.lifespan. If FastAPI has no lifespan of its own, pass that directly with
FastAPI(lifespan=mcp_app.lifespan). If it has one, combine_lifespans enters several lifespans in order and exits
them in reverse:
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastmcp import FastMCP
from fastmcp.utilities.lifespan import combine_lifespans
import store
mcp = FastMCP("docs-assistant")
started = {"app": False}
@mcp.tool(annotations={"readOnlyHint": True})
def search_docs(query: str, component: str, top_k: int = 3) -> dict:
"""Search the documentation of one component."""
return {"query": query, "component": component, "hits": store.search(query, component, top_k)}
@asynccontextmanager
async def app_lifespan(app: FastAPI):
started["app"] = True # e.g. open a database pool here
yield
mcp_app = mcp.http_app(path="/mcp")
app = FastAPI(lifespan=combine_lifespans(app_lifespan, mcp_app.lifespan))
@app.get("/api/health")
def health() -> dict:
return {"app_lifespan_ran": started["app"]}
app.mount("/", mcp_app)
Run with uvicorn fastmcp_lifespan:app, /api/health returned {"app_lifespan_ran": true} and the Inspector listed
search_docs on /mcp, so both lifespans ran. Forgetting the lifespan fails in the same way as in option 1, with a
friendlier message: FastMCP catches the error and raises a RuntimeError that says the session manager’s task group
"was not initialized", that this commonly happens when the FastMCP application’s lifespan is not passed to the parent
ASGI application, and points to its lifespan=mcp_app.lifespan fix. It still surfaces as an HTTP 500 on the first
request. The FastMCP documentation describes the pattern on
its FastAPI integration page, including mounting under a path prefix and
the caution about layering application-wide CORS middleware on top of an OAuth-protected MCP mount.
Option 3: generate tools from the OpenAPI document
If the REST API already exists, two libraries will turn its operations into MCP tools without you writing any. Both read the OpenAPI document FastAPI generates and, when a tool is called, make the corresponding HTTP request to the REST app.
"""A small REST API whose OpenAPI document feeds the auto-generation examples."""
from fastapi import FastAPI, HTTPException
import store
api = FastAPI(title="docs-api")
@api.get("/components", operation_id="list_components")
def list_components() -> list[str]:
return store.COMPONENTS
@api.get("/search", operation_id="search_docs")
def search(query: str, component: str, top_k: int = 3) -> list[dict]:
return store.search(query, component, top_k)
@api.delete("/pages/{component}/{page}", operation_id="delete_page")
def delete_page(component: str, page: str) -> dict:
"""A destructive endpoint that auto-generation would expose as a tool."""
raise HTTPException(status_code=501, detail="not implemented in the demo")
FastMCP does it in one line, FastMCP.from_fastapi(app=api). It keeps FastAPI’s operation_id as the tool name, which
is why the routes above declare explicit ids; without them the generated names are derived from the path and method
(for example list_products_products_get), which is a poor name for a model to choose between.
fastapi-mcp does it with FastApiMCP(api).mount_http(), which adds the MCP endpoint at /mcp on the same FastAPI
app. It is built on the v1 SDK (mcp below 2). Installing it into an environment that has mcp 2.x downgrades
mcp to 1.30 and breaks FastMCP 4, which imports v2-only modules, so give it its own virtual environment. It works
with the v1 SDK’s protocol handling; the Inspector connected to it in the earlier (legacy) protocol era.
Run against the API above, both produced the same three tools, one per operation (names and annotations as
tools/list reported them):
list_components (no annotations)
search_docs (no annotations)
delete_page (no annotations)
Read that output before deploying it. The generated delete_page tool is exposed to the model exactly like the search
tool, with no destructiveHint, no confirmation and no distinction between reading and deleting; every REST operation
became model-callable. There are two ways to narrow the surface. FastMCP takes route maps that exclude by method, and
fastapi-mcp takes allow or deny lists of operation ids and tags:
from fastmcp import FastMCP
from fastmcp.server.providers.openapi import MCPType, RouteMap
from rest_api import api
mcp = FastMCP.from_fastapi(
app=api,
name="docs-api-mcp",
route_maps=[RouteMap(methods=["DELETE", "POST", "PUT", "PATCH"], mcp_type=MCPType.EXCLUDE)], # read-only surface
)
from fastapi_mcp import FastApiMCP
from rest_api import api
mcp = FastApiMCP(api, name="docs-api-mcp", include_operations=["list_components", "search_docs"])
mcp.mount_http()
Each was run and listed exactly list_components and search_docs. Prefer an allow list (include_operations) to a deny
list: a new destructive endpoint added to the REST API next month must not become a model-callable tool by default.
Why curated tools beat generated ones
Generation is a good way to start, and its authors say so. FastMCP’s documentation warns that models perform markedly better against a well-designed, curated MCP server than against an auto-converted OpenAPI one, especially for APIs with many endpoints and parameters, and it recommends the integration for bootstrapping and prototyping rather than for mirroring an API to model clients. The reasons follow from how a model uses tools, which Designing Good MCP Servers treats in depth:
-
Granularity. REST endpoints are shaped for programmers composing calls; a tool should be shaped for a task. Ten CRUD endpoints become ten tools whose descriptions the model must read on every request, when one
search_docstool with a good description does the job. -
Descriptions and names. Generated descriptions are docstrings and path summaries written for API readers. A tool’s description is the model’s only instruction for when and how to call it.
-
Outputs. REST responses are complete resource representations. A tool should return what the model needs, in a bounded, typed shape; the
SearchResultmodel ofdocs-assistantreturns a snippet and a score, not the whole page. -
Safety. Every operation becomes callable, including destructive ones, unless you filter. A curated server makes each exposure a decision.
-
Identity. Generated tools call the REST API as an HTTP client. With FastMCP the credentials are static
httpx_client_kwargsheaders, the same for every MCP caller; fastapi-mcp forwards the caller’sAuthorizationheader by default. Neither gives the per-user, audience-checked model that the next section builds by hand.
A workable compromise is to generate for exploration, then replace the generated tools you keep with hand-written ones that call the same service layer.
Option 4: share authentication and dependencies with the REST API
Whatever you mounted, the goal is that the same rules protect the same data whichever door a caller uses. FastAPI
dependencies (Depends) are a FastAPI feature: they run for FastAPI routes, not for a mounted ASGI application, so an
MCP tool never sees them. Sharing therefore means sharing the objects behind the dependencies, and giving each side
a thin adapter. Three things are worth sharing:
-
The token verifier. One class decides whether a bearer token is acceptable. The REST side wraps it in a dependency; the MCP side passes it as
token_verifiertoMCPServer, which then enforces it, adds theWWW-Authenticatechallenge and serves the Protected Resource Metadata (see Authorization). -
The service layer. The logic behind both is a plain module or class. Neither the REST route nor the MCP tool owns it, so the rules cannot drift.
-
Application resources. Database pools and clients opened in the host lifespan are reachable from both sides.
"""One FastAPI process, one token verifier, one service: REST routes and MCP tools share all three."""
import os
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Annotated
import anyio
import jwt
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jwt import PyJWKClient
from mcp.server import MCPServer
from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.auth.provider import AccessToken
from mcp.server.auth.settings import AuthSettings
from mcp.types import ToolAnnotations
from pydantic import BaseModel
from service import DocsService, docs_service
ISSUER = os.environ["MCP_ISSUER"]
AUDIENCE = os.environ["MCP_AUDIENCE"]
JWKS_URI = os.environ["MCP_JWKS_URI"]
class JwtVerifier:
"""The single place that decides whether a bearer token is acceptable."""
def __init__(self) -> None:
self._jwks = PyJWKClient(JWKS_URI)
async def verify_token(self, token: str) -> AccessToken | None:
try:
# PyJWKClient fetches keys with blocking HTTP: run it off the event loop so a JWKS refresh
# does not stall every REST and MCP request in this process.
signing_key = await anyio.to_thread.run_sync(self._jwks.get_signing_key_from_jwt, token)
key = signing_key.key
claims = jwt.decode(token, key, algorithms=["RS256", "ES256"], issuer=ISSUER, audience=AUDIENCE,
options={"require": ["exp", "iss", "aud"]})
except jwt.PyJWTError:
return None
return AccessToken(token=token, client_id=str(claims.get("azp", "unknown")),
scopes=str(claims.get("scope", "")).split(), expires_at=int(claims["exp"]),
resource=AUDIENCE, subject=claims.get("sub"))
verifier = JwtVerifier()
bearer = HTTPBearer(auto_error=False)
# --- REST side: a FastAPI dependency wrapping the shared verifier -------------------------------------------
async def current_token(creds: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer)]) -> AccessToken:
token = await verifier.verify_token(creds.credentials) if creds else None
if token is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid or missing token",
headers={"WWW-Authenticate": "Bearer"})
if "docs:read" not in token.scopes:
raise HTTPException(status.HTTP_403_FORBIDDEN, "insufficient scope")
return token
def service() -> DocsService:
return docs_service
# --- MCP side: the same verifier, the same service ----------------------------------------------------------
class Hit(BaseModel):
component: str
page: str
title: str
score: int
snippet: str
class SearchResult(BaseModel):
query: str
component: str
caller: str | None
hits: list[Hit]
mcp = MCPServer(
"docs-assistant",
version="1.0.0",
token_verifier=verifier,
auth=AuthSettings(issuer_url=ISSUER, resource_server_url=AUDIENCE, required_scopes=["docs:read"],
validate_token_resource=True),
)
@mcp.tool(annotations=ToolAnnotations(readOnlyHint=True, openWorldHint=False))
async def search_docs(query: str, component: str, top_k: int = 3) -> SearchResult:
"""Search the documentation of one component."""
token = get_access_token() # the token the MCP middleware already verified
hits = docs_service.search(query, component, top_k)
return SearchResult(query=query, component=component, caller=token.subject if token else None,
hits=[Hit(**h) for h in hits])
mcp_app = mcp.streamable_http_app()
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
async with mcp.session_manager.run():
yield
app = FastAPI(title="docs-assistant", lifespan=lifespan)
@app.get("/api/search")
def rest_search(query: str, component: str, top_k: int = 3, _: AccessToken = Depends(current_token),
svc: DocsService = Depends(service)) -> list[dict]:
return svc.search(query, component, top_k)
app.mount("/", mcp_app)
service.py is the shared layer both search paths call:
"""Business logic shared by the REST routes and the MCP tools. Neither transport owns it."""
import store
class DocsService:
def search(self, query: str, component: str, top_k: int = 3) -> list[dict]:
if component not in store.COMPONENTS:
raise ValueError(f"Unknown component '{component}'")
return store.search(query, component, top_k)
docs_service = DocsService()
The issuer, audience and JWKS location come from the environment (MCP_ISSUER, MCP_AUDIENCE, MCP_JWKS_URI), never
from source. The module was run with a throw-away RSA key, a local JWKS endpoint and three test tokens minted from that key
(valid, valid but without the docs:read scope, and valid but issued for a different audience). Each was sent to the REST
route and to the MCP endpoint:
| Request | REST /api/search |
MCP POST /mcp |
|---|---|---|
No token |
401 |
401 with a |
Valid token with |
200 |
200; the tool’s |
Valid token without the scope |
403 |
403 |
Token issued for another audience |
401 |
401 |
Three points make this safe rather than merely convenient:
-
The audience check is the same on both sides.
jwt.decode(…, audience=AUDIENCE)is what rejects a token minted for another resource, and the REST side inherits it from the same verifier. MCP’s rule against token passthrough applies here too: do not accept an upstream API’s token because your REST API happens to accept it. -
Only the verifier crosses the boundary.
get_access_token()returns the token the SDK’s middleware already verified, so the tool can make per-user decisions (here it just echoessub) without re-parsing headers. -
Both routes stay least-privilege. The scope is required on both, and the tool carries
readOnlyHintas a hint to the host, not as an enforcement mechanism.
The libraries that generate tools have their own hooks for the same goal. fastapi-mcp accepts
auth_config=AuthConfig(dependencies=[Depends(…)]), which runs FastAPI dependencies on the MCP endpoint, and by default
forwards the incoming Authorization header to the REST call. That forwarding hands the client’s MCP token to the REST
application, which is acceptable only when both are one resource with one audience, as they are inside a single
process; the specification forbids passing an MCP token on to a different upstream API. With FastMCP.from_fastapi,
credentials for the internal REST calls go through httpx_client_kwargs. Read them from the environment rather than
embedding a token in code, and remember that a static header means every MCP caller acts as the same REST identity.
Running and operating the combined app
-
One process, one failure domain. A slow MCP tool and a slow REST route compete for the same event loop and the same workers. Keep blocking work in
defhandlers, which the SDK runs on worker threads, or in a thread pool. -
Probes. Health-check a path that proves MCP is alive, such as a
server/discoverortools/listrequest, not only/api/health; the failing version of option 1 passes a REST-only probe. -
Scaling. For clients on the 2026-07-28 protocol, Streamable HTTP requests are independent, so
uvicorn --workers Nand several replicas work without sticky sessions; thestateless_httprun option is listed on Python MCP Servers and the load-balancing rules on Transports. An elicitation retry can reach a different worker, so every worker needs the sameRequestStateSecuritykey (see Tools, Resources, Prompts & Elicitation and Deploying Remote Servers). -
Packaging. The container material on the site applies unchanged: see Dockerfile Fundamentals for building the image and Image Best Practices and Supply-Chain Security for non-root and minimal images.
-
Two apps instead of one. If the REST and MCP sides need different scaling or release cadence, run them as two deployments sharing the service library. The mount is a convenience, not an obligation.