MCP server generated from_fastapi defaults does not respect fastapi lifespan function
Description
I am not sure if this is a bug or an enhancement request so I apologize in advance if I created the wrong type of issue. I am currently running the following versions
python = 3.12
fastmcp = 2.4.0
fastapi = 0.109.2
If I create an app with an mcp server generated from a fast api server and mount the mcp server and the fast api server under a starlette app, the documentation and the underlying fastmcp server implementation both don't give me a way to pass in a lifespan function.
I have attached a full example below.
-
If you run line 56, it does not print the "Starting my application" in the lifespan function of the fastapi.
-
If you comment out line 56 and uncomment line 57, it does print the lifespan function statement.
Expected output:
[05/23/25 17:54:41] INFO Created FastMCP OpenAPI server with 1 routes openapi.py:632
INFO: Started server process [45767]
INFO: Waiting for application startup.
Starting my application
INFO: Application startup complete.
INFO: Uvicorn running on http://localhost:8000 (Press CTRL+C to quit)
Actual output:
[05/23/25 18:01:12] INFO Created FastMCP OpenAPI server with 1 routes openapi.py:632
INFO: Started server process [53696]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://localhost:8000 (Press CTRL+C to quit)
It does very much look like from the .from_fastapi implementation that the default_lifespan function is what is created underneath the hood. This gives me no options to pass in my own lifespan functions or for fastmcp to execute the fastapi's lifespan functon. I see no documentation other than to pass the mcp_app.lifespan function to the starlette app.
How can I write my own lifespan function that will handle both the fastapi and the mcp server's lifecycle actions?
Any help would be deeply appreciated?
Example Code
from contextlib import asynccontextmanager
from typing import AsyncIterator
from fastapi import FastAPI
from fastmcp import FastMCP
from mcp import ServerSession
from starlette.applications import Starlette
from starlette.routing import Mount
@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncIterator[None]:
print("Starting my application")
yield
print("Closing my application")
################################## DO NOT EDIT ##################################################
# Temporary monkeypatch which avoids crashing when a POST message is received
# before a connection has been initialized, e.g: after a deployment.
# pylint: disable-next=protected-access
old__received_request = ServerSession._received_request
async def _received_request(self: ServerSession, *args, **kwargs): # noqa: ANN202,ANN001,ANN002,ANN003
try:
return await old__received_request(self, *args, **kwargs)
except RuntimeError:
pass
# pylint: disable-next=protected-access
ServerSession._received_request = _received_request
#################################################################################################
fastapi_app = FastAPI(lifespan=lifespan)
# Write a simple route for fast api
@fastapi_app.get("/")
async def get_hello_world(name: str = "World"):
return {"Hello": name}
mcp_server = FastMCP.from_fastapi(fastapi_app).http_app(transport='streamable-http')
# mount both apps on a starlette app
app = Starlette(
routes=[Mount("/mcp-server", app=mcp_server, name="mcp_app"), Mount("/", app=fastapi_app, name="fastapi_app")],
lifespan=mcp_server.lifespan
)
if __name__ == "__main__":
import uvicorn
# Run the app with uvicorn
uvicorn.run(app, host="localhost", port=8000) # Uncomment this line to run the combined app
# uvicorn.run(fastapi_app, host="localhost", port=8000) # comment to only run the fastapi app
Version Information
FastMCP version: 2.4.0
MCP version: 1.9.1
Python version: 3.12.10
Platform: macOS-15.5-arm64-arm-64bit
Additional Context
Any updates to the documentation with examples on how to mount a fast api app and an mcp app created from the fast-api app would be highly useful.
No response
I think I understand the issue -- when we create a MCP server from a FastAPI app, we use the in-memory ASGI transport to communicate with the app, turning MCP requests into HTTP requests and forwarding them. However what you're pointing out is that the FastAPI app's lifecycle never gets triggered when that happens (right?)
I believe this is because the httpx ASGITransport does not trigger the lifecycle but we could use https://github.com/florimondmanca/asgi-lifespan to do it. There's a chance this would trigger the lifespan on each invocation, but I think the only way to avoid that in all circumstances is to host the FastAPI app as a long-running instance anyway.
Or sorry maybe I've misunderstood -- if not the above, is it that you want a way to pass both lifespans (the MCP server and the FastAPI app) to your starlette app at once? In that case I think they could be combined into a joint context manager.
I think the right path forward is to do some kind of joint context manager at least the way see it. Some lifespan function that you pass into the Starlette application instance.
Something like this:
app = Starlette([
routes=Mount("/mcp-server", app=mcp_server, name="mcp_app"), Mount("/", app=fastapi_app, name="fastapi_app")],
lifespan=joint_lifespan)
def joint_lifespan(_: FastAPI | FastMCP):
if isinstance(_, FastAPI):
# do fastapi app initialization
yield
# do fastapi app shutdown
if isinstance(_, FastMCP):
# do fastmcp stuff
yield {} # from fastapi.server.default_lifespan
# do fastmcp shutdown
To me any kind of advice or update to the documentation would help.
I ran into something similar. I have an existing FastAPI app and I was hoping of using FastMCP to expose a subset of the FastAPI endpoints as MCP tools. Something along those lines:
I'm not sure if this is a scenario from_fastapi is meant to be used in?
This approach seems to do the trick.
def combine_lifespans(*lifespans):
@asynccontextmanager
async def combined_lifespan(app):
async with AsyncExitStack() as stack:
for lifespan in lifespans:
await stack.enter_async_context(lifespan(app))
yield
return combined_lifespan
# Api app lifespan
@asynccontextmanager
async def api_app_lifespan(_app: FastAPI):
# do some setup here
yield
api_app = FastAPI(title="Api App")
mcp_app = FastMCP.from_fastapi(api_app).http_app()
app = FastAPI(
title="Main App",
lifespan=combine_lifespans(
api_app_lifespan,
mcp_app.lifespan,
),
)
app.mount("/api", api_app)
app.mount("/mcp-server", mcp_app)
Dang @pouellet I had a very similar solution written out yesterday but forgot to post it. This is much cleaner imo. I hadn't thought of mounting the MCP server on a FastAPI server. I guess the solution is the same even if you build it as a Starlette app similar to the example above?
Yes, a joint lifespan manager is def the way forward because Starlette / FastAPI et al won't automatically run lifespans unless you yank them up to the parent app (unfortunately!). Maybe we can make an easier utility for composing async context managers though
Just confirmed, this works if your app is a Starlette app as well.
Thanks @pouellet , your example really helped.
I ended up with this version:
# <package>/entrypoints/mcp.py
from contextlib import AsyncExitStack, asynccontextmanager
from fastapi import FastAPI
from fastmcp import FastMCP
from <package>.entrypoints.api import app as api_app
from typing import AsyncContextManager, Callable, Any
def combine_lifespans(*lifespans: Callable[[Any], AsyncContextManager[Any]]):
@asynccontextmanager
async def combined_lifespan(app: Any):
async with AsyncExitStack() as stack:
for lifespan in lifespans:
await stack.enter_async_context(lifespan(app))
yield
return combined_lifespan
mcp_app = FastMCP[AsyncContextManager[None]].from_fastapi(api_app).http_app(path="/" ,transport="streamable-http")
app = FastAPI(
title="MCP",
lifespan=combine_lifespans(
api_app.router.lifespan_context,
mcp_app.lifespan,
),
)
app.mount("/mcp", mcp_app)
app.mount("/", api_app)
app.openapi = api_app.openapi
Main changes:
- Used
api_app.router.lifespan_contextdirectly - Tools work fine at
http://localhost:8000/mcp/with MCP Inspector
Appreciate the help.
I tried the above solution and got a 'State' object has no attribute 'my_client' from my api_app trying to to curl to it.
Is this expected? I cannot access a variable defined in my api_app's lifespan.
I tried the above solution and got a
'State' object has no attribute 'my_client'from my api_app trying to to curl to it.Is this expected? I cannot access a variable defined in my api_app's lifespan.
Yes same here, I can't manage to run a /mcp server along with an existing (and working) Fastapi app. It seems that the lifespans end up being removed somehow (or never run)
I've added a recipe here that I think reflects the helpful advice in this thread. (via #1198)