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.

flowchart LR H[MCP host or agent] -->|POST /mcp| U[ASGI server: uvicorn] R[Browser, service or script] -->|GET /api/search| U U --> F[FastAPI app] F -->|routes declared first| API["REST routes /api/*"] F -->|Mount at /, declared last| M["MCP ASGI app: streamable_http_app()"] M --> S[MCPServer tools, resources, prompts] F -.->|lifespan enters session_manager.run| SM[Session manager] M --> SM API --> SVC[Shared service and store] S --> SVC API --> V[Shared token verifier] S --> V
Figure 1. One process serving REST and MCP -- the mount, the lifespan and what the two sides share

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

mcp.streamable_http_app() mounted in FastAPI, with the host app entering mcp.session_manager.run().

You use MCPServer and want the smallest dependency set. Hand-written tools.

2. Mount a FastMCP app

fastmcp 4’s http_app(), with its lifespan merged in using combine_lifespans.

You use FastMCP for its extra features, or already run FastMCP servers.

3. Generate from OpenAPI

FastMCP.from_fastapi() or fastapi-mcp turn REST operations into tools.

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:

broken_app.py — no lifespan on the host app
"""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:

fixed_app.py — the host app owns 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-mcp with streamable_http_app(streamable_http_path="/"), the endpoint answered on /docs-mcp/ (the version without the trailing slash returned 307, so configure clients with the slash) and /docs-mcp/mcp returned 404.

  • Host allowlist. The app answers only localhost Host headers by default. A request with Host: example.com got 421 Invalid Host header. Behind a real hostname pass transport_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:

fastmcp_lifespan.py — an application lifespan combined with FastMCP’s
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.

rest_api.py — a small REST API whose OpenAPI document feeds the examples
"""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_docs tool 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 SearchResult model of docs-assistant returns 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_kwargs headers, the same for every MCP caller; fastapi-mcp forwards the caller’s Authorization header 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:

  1. 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_verifier to MCPServer, which then enforces it, adds the WWW-Authenticate challenge and serves the Protected Resource Metadata (see Authorization).

  2. 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.

  3. Application resources. Database pools and clients opened in the host lifespan are reachable from both sides.

shared_app.py — one verifier and one service, used by a REST route and an MCP tool
"""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 WWW-Authenticate challenge

Valid token with docs:read

200

200; the tool’s caller field held the token’s sub

Valid token without the scope

403

403 insufficient_scope

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 echoes sub) without re-parsing headers.

  • Both routes stay least-privilege. The scope is required on both, and the tool carries readOnlyHint as 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 def handlers, 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/discover or tools/list request, 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 N and several replicas work without sticky sessions; the stateless_http run 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 same RequestStateSecurity key (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.