- Published on
Running an MCP server inside an existing Quart app
- Authors
- Name
- Markus Palme
Let's assume we have an existing Quart app. Now we want AI agents to use it too, so we want an MCP server on the same API/same host and in the same process so business logic can be reused.
FastMCP makes writing MCP tools easy and is the de-facto standard for Python, but it ships its own web stack. Thanks to Python's ASGI abstraction (the standard interface between web servers and apps), a few lines of Starlette can mount both side by side in one app.
This post walks through that setup, plus the issues I hit along the way. And by the way, this is approach is not limited to Quart + FastMCP - it really applies to every server that can talk ASGI, more on that later.
The problem: two apps, one process
FastMCP's quickstart calls mcp.run(transport="http"). That starts FastMCP's own Uvicorn server on its own port. Our Quart app, meanwhile, is already started by Uvicorn or Hypercorn with something like uvicorn main:app. We end up with two options that both don't work:
- Two servers, two ports. We need a second process, configuration model, etc. and have to extract business logic into a shared module
- A FastMCP route inside Quart. Quart routes are Quart request handlers, but the MCP Streamable HTTP transport is a complete Starlette app with its own session manager. We can't register it as a Quart view function.
What if we could have /api/... handled by Quart, and /tools/mcp handled by FastMCP?
ASGI
An ASGI app is just an async callable with a fixed signature:
async def app(scope, receive, send): ...
scope says what kind of connection this is ("http", "websocket" or "lifespan") and, for HTTP, which path was requested. The server calls the app once per connection, plus once at startup for the lifespan protocol.
A Quart app is an ASGI app. So is the Starlette app that FastMCP builds for its HTTP transport (mcp.http_app()). Because they share one interface, a third ASGI app can sit in front of both and pick a target by path (think of a middleware in the request pipeline that dispatches requests based on their path). Starlette's Mount does exactly that. It strips the prefix it matched and forwards the request to the mounted app.
Uvicorn only sees the Starlette parent. The parent sends /tools/* to FastMCP, sends everything else to Quart, and starts both apps at startup.
Step by step
Install the required packages:
pip install quart fastmcp starlette uvicorn
1. The existing Quart app
Nothing changes on the Quart side. In our example, let's define a simple health endpoint and a lifecycle hook for startup:
from quart import Quart, current_app
quart_app = Quart(__name__)
quart_app.config["GREETING"] = "Hello"
@quart_app.before_serving
async def startup():
# open database pools, warm caches, build service clients ...
quart_app.logger.info("Quart startup ran")
@quart_app.get("/api/health")
async def health():
return {"status": "ok"}
2. The MCP server and a tool
A FastMCP tool is a plain function. FastMCP builds the JSON schema from the type hints and the tool description from the docstring:
from datetime import datetime, timezone
from fastmcp import FastMCP
mcp = FastMCP("My Quart MCP Server")
@mcp.tool
async def greet(name: str) -> str:
"""Greet someone by name and tell them the current UTC time."""
async with quart_app.app_context():
greeting = current_app.config["GREETING"]
now = datetime.now(timezone.utc).strftime("%H:%M UTC")
return f"{greeting}, {name}! It is {now}."
The app_context() block is important here. The lessons learned section explains why we need to have it.
Next, turn the server into an ASGI app. Crucially, we don't call mcp.run():
mcp_http_app = mcp.http_app(path="/mcp", stateless_http=True)
3. One lifespan for both apps
Both apps may need startup work. Quart runs its before_serving hooks, and FastMCP starts the session manager behind the Streamable HTTP transport. Normally each app gets this from the server through the ASGI lifespan protocol. Under a parent app, only the parent receives lifespan events, so it has to pass them on:
import contextlib
@contextlib.asynccontextmanager
async def lifespan(app):
async with mcp_http_app.lifespan(app):
await quart_app.startup()
try:
yield
finally:
await quart_app.shutdown()
4. The parent Starlette app
This is where the real magic happens and the two apps are combined into one:
from starlette.applications import Starlette
from starlette.middleware.cors import CORSMiddleware
from starlette.routing import Mount
app = Starlette(
routes=[
Mount("/tools", app=mcp_http_app),
Mount("", app=quart_app),
],
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["https://my-frontend.example.com"],
allow_methods=["GET", "POST", "DELETE"],
allow_headers=["*"],
expose_headers=["mcp-session-id"],
)
The order of routes matters: Starlette tries to route from top to bottom and the Mount("") matches everything and should thus sit at the end to handle everything but the MCP (catch-all).
Running and testing it
We point the ASGI server at the Starlette app, not at quart_app:
uvicorn app:app --port 8000
The Quart side is unchanged:
curl localhost:8000/api/health
# {"status":"ok"}
FastMCP also ships a client, which makes it easy to invoke a tool:
import asyncio
from fastmcp import Client
async def main():
async with Client("http://localhost:8000/tools/mcp") as client:
tools = await client.list_tools()
print("Tools:", [t.name for t in tools])
result = await client.call_tool("greet", {"name": "Ada"})
print("Result:", result.data)
asyncio.run(main())
Tools: ['greet']
Result: Hello, Ada! It is 15:59 UTC.
Any MCP client that speaks the Streamable HTTP transport can now connect to http://localhost:8000/tools/mcp.
Lessons learned
Forget the lifespan and every MCP call fails
If we leave out lifespan=lifespan, the app still starts and the Quart routes still answer. The first MCP call then fails with an HTTP 500:
RuntimeError: Task group is not initialized. Make sure to use run().
The Streamable HTTP session manager is started inside FastMCP's lifespan, and Starlette does not forward lifespan events to mounted apps. Quart's before_serving hooks are skipped the same way, which is easy to miss when the health route answers either way. Also remember the quart_app.shutdown() in the finally block. My first version only called startup(), so Quart's shutdown hooks never ran.
Tools run outside Quart's app context
A request to /tools/mcp never goes through Quart, so Quart never pushes an app context. Anything that relies on current_app (config, extensions, a dependency injector stored on the app) raises:
RuntimeError: Not within an app context
This is easy to miss because a tool that doesn't touch Quart, like a hello world example, works fine. Either we push the context ourselves (async with quart_app.app_context(): as in greet above), or hold a direct reference to quart_app and read quart_app.config and quart_app.extensions directly. Request-scoped things like request and g aren't available at all. MCP tools get their request data from their arguments.
Quart's auth doesn't protect the MCP endpoint
Auth decorators and before_request hooks on our Quart routes do nothing for /tools/mcp, so the endpoint is public by default. We can use FastMCP's auth providers to validate bearer tokens and point them at the same identity provider and audience as our API, so a client can use one token for both.
Full example
Tested with Python 3.13, fastmcp 4.0.11, Quart 0.23.1, Starlette 1.7.0 and Uvicorn. Run it with uvicorn app:app --port 8000.
# app.py
import contextlib
from datetime import datetime, timezone
from fastmcp import FastMCP
from quart import Quart, current_app
from starlette.applications import Starlette
from starlette.middleware.cors import CORSMiddleware
from starlette.routing import Mount
# 1. The existing Quart app
quart_app = Quart(__name__)
quart_app.config["GREETING"] = "Hello"
@quart_app.before_serving
async def startup():
quart_app.logger.info("Quart startup ran")
@quart_app.get("/api/health")
async def health():
return {"status": "ok"}
# 2. The MCP server with one tool
mcp = FastMCP("My Quart MCP Server")
@mcp.tool
async def greet(name: str) -> str:
"""Greet someone by name and tell them the current UTC time."""
async with quart_app.app_context():
greeting = current_app.config["GREETING"]
now = datetime.now(timezone.utc).strftime("%H:%M UTC")
return f"{greeting}, {name}! It is {now}."
mcp_http_app = mcp.http_app(path="/mcp", stateless_http=True)
# 3. One lifespan that starts and stops both apps
@contextlib.asynccontextmanager
async def lifespan(app):
async with mcp_http_app.lifespan(app):
await quart_app.startup()
try:
yield
finally:
await quart_app.shutdown()
# 4. The parent ASGI app that routes by path
app = Starlette(
routes=[
Mount("/tools", app=mcp_http_app),
Mount("", app=quart_app),
],
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["https://my-frontend.example.com"],
allow_methods=["GET", "POST", "DELETE"],
allow_headers=["*"],
expose_headers=["mcp-session-id"],
)
Comments
Loading comments…