Skip to content

FastMCP breaks FastAPI Testclient #2375

@vorwerkc

Description

@vorwerkc

Description

Mounting an FastMCP http_app leads to errors when testing the FastAPI endpoints using fastapi.testclient.TestClient.

Consider the following minimal app in example_api/app.py:

from contextlib import asynccontextmanager
from fastapi import FastAPI

from fastmcp import FastMCP
    
@asynccontextmanager
async def lifespan(app: FastAPI):
    """Define FastAPI lifespan."""
    print('Starting FastAPI app')
    try:
        yield
    finally:
        print('Stopping FastAPI app')

mcp = FastMCP('tools')
mcp_app = mcp.http_app(path='/mcp', transport='http')

@mcp.tool
def add_two_numbers(first: float, second: float) -> float:
    """Add two numbers."""
    return first + second

@asynccontextmanager
async def combined_lifespan(app: FastAPI):
    """Run both lifespans."""
    async with lifespan(app):
        async with mcp_app.lifespan(app):
            yield

app = FastAPI(lifespan=combined_lifespan)

@app.get('/health')
def health():
    """Return health status."""
    return {'status': 'ok'}

app.mount('/analytics', mcp_app)

and a test like this:

from fastapi.testclient import TestClient
from example_app.app import app

def test_health():
    with TestClient(app) as client:
        response = client.get('/health')
        assert response.status_code == 200

def test_health2():
    with TestClient(app) as client:
        response = client.get('/health')
        assert response.status_code == 200

Running the tests with pytest test_app.py leads to the following error:

RuntimeError: StreamableHTTPSessionManager .run() can only be called once per instance. Create a new instance if you need to run again.

The bug has already been found in this issue: #946 and in a linked blog a solution was proposed. Changing the app to following fixes the error:

from contextlib import asynccontextmanager
from fastapi import FastAPI

from fastmcp import FastMCP

class CustomFastMCP():
    def __init__(self, mcp):
        self.mcp = mcp
    
    def create_app(self):
        self.app = self.mcp.http_app(path='/mcp', transport='http')
        self.generator = self.app.lifespan(self.app)
        self.generator_started = False
        return self.app
    
    async def start_session(self):
        if self.generator_started:
            return
        await self.generator.__aenter__()
        self.generator_started = True
    
@asynccontextmanager
async def lifespan(app: FastAPI):
    """Define FastAPI lifespan."""
    print('Starting FastAPI app')
    try:
        yield
    finally:
        print('Stopping FastAPI app')

mcp = FastMCP('tools')
custom = CustomFastMCP(mcp)

@mcp.tool
def add_two_numbers(first: float, second: float) -> float:
    """Add two numbers."""
    return first + second

@asynccontextmanager
async def combined_lifespan(app: FastAPI):
    """Run both lifespans."""
    async with lifespan(app):
        await custom.start_session() 
        yield

app = FastAPI(lifespan=combined_lifespan)

@app.get('/health')
def health():
    """Return health status."""
    return {'status': 'ok'}

app.mount('/analytics', custom.create_app())

Example Code

from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.testclient import TestClient
from fastmcp import FastMCP
    
@asynccontextmanager
async def lifespan(app: FastAPI):
    """Define FastAPI lifespan."""
    print('Starting FastAPI app')
    try:
        yield
    finally:
        print('Stopping FastAPI app')

mcp = FastMCP('tools')
mcp_app = mcp.http_app(path='/mcp', transport='http')

@mcp.tool
def add_two_numbers(first: float, second: float) -> float:
    """Add two numbers."""
    return first + second

@asynccontextmanager
async def combined_lifespan(app: FastAPI):
    """Run both lifespans."""
    async with lifespan(app):
        async with mcp_app.lifespan(app):
            yield

app = FastAPI(lifespan=combined_lifespan)

@app.get('/health')
def health():
    """Return health status."""
    return {'status': 'ok'}

app.mount('/analytics', mcp_app)

def test_health():
    with TestClient(app) as client:
        response = client.get('/health')
        assert response.status_code == 200

def test_health2():
    with TestClient(app) as client:
        response = client.get('/health')
        assert response.status_code == 200

Version Information

FastMCP version:                                                                          2.13.0.2
MCP version:                                                                                1.20.0
Python version:                                                                            3.10.18
Platform:                                                                Windows-10-10.0.19044-SP0

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working. Reports of errors, unexpected behavior, or broken functionality.httpRelated to HTTP transport, networking, or web server functionality.tests

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions