fastmcp icon indicating copy to clipboard operation
fastmcp copied to clipboard

MCP server generated from_fastapi defaults does not respect fastapi lifespan function

Open karthich opened this issue 6 months ago • 8 comments

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

karthich avatar May 23 '25 23:05 karthich

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.

jlowin avatar May 23 '25 23:05 jlowin

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.

jlowin avatar May 23 '25 23:05 jlowin

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.

karthich avatar May 26 '25 19:05 karthich

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:

Image

I'm not sure if this is a scenario from_fastapi is meant to be used in?

pouellet avatar May 28 '25 00:05 pouellet

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)

pouellet avatar May 28 '25 16:05 pouellet

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?

karthich avatar May 28 '25 16:05 karthich

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

jlowin avatar May 28 '25 18:05 jlowin

Just confirmed, this works if your app is a Starlette app as well.

karthich avatar May 28 '25 18:05 karthich

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_context directly
  • Tools work fine at http://localhost:8000/mcp/ with MCP Inspector

Appreciate the help.

FarDust avatar Jun 26 '25 15:06 FarDust

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.

NotHolmes avatar Jun 30 '25 11:06 NotHolmes

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)

chainyo avatar Jul 02 '25 14:07 chainyo

I've added a recipe here that I think reflects the helpful advice in this thread. (via #1198)

jlowin avatar Jul 21 '25 14:07 jlowin