fastmcp icon indicating copy to clipboard operation
fastmcp copied to clipboard

How to combine multiple HTTP MCP servers together?

Open flobaader opened this issue 7 months ago • 1 comments

Enhancement Description

Based on the documentation you provided, I can help you write an issue for the FastMCP project maintainer regarding the challenge of mounting two Streamable HTTP servers together. Here's a well-structured issue:


Title: Unable to mount multiple FastMCP Streamable HTTP servers due to lifespan conflicts

Description:

I'm trying to mount two FastMCP servers with Streamable HTTP transport in a single ASGI application, but I'm encountering issues with the lifespan management requirement.

Current Behavior:

According to the documentation, when using Streamable HTTP transport, we must pass the lifespan context from the FastMCP app to the outer ASGI app to ensure proper session manager initialization. However, when trying to mount multiple FastMCP servers, this creates a conflict since:

  1. Each FastMCP server has its own lifespan context
  2. The outer ASGI app can only accept one lifespan parameter
  3. Nested lifespans are not recognized (as stated in the docs)

Example Code:

from fastmcp import FastMCP
from starlette.applications import Starlette
from starlette.routing import Mount

# Create first FastMCP server
mcp1 = FastMCP("Server1")
mcp1_app = mcp1.http_app(path='/mcp')

# Create second FastMCP server
mcp2 = FastMCP("Server2")
mcp2_app = mcp2.http_app(path='/mcp')

# Problem: Which lifespan to use?
app = Starlette(
    routes=[
        Mount("/server1", app=mcp1_app),
        Mount("/server2", app=mcp2_app),
    ],
    lifespan=mcp1_app.lifespan,  # This only initializes mcp1's session manager
    # lifespan=mcp2_app.lifespan,  # Can't pass both lifespans
)

Expected Behavior:

There should be a way to mount multiple FastMCP Streamable HTTP servers in a single application with all session managers properly initialized.

Potential Solutions:

  1. Could FastMCP provide a way to combine multiple lifespans?
  2. Could there be a method to create a shared session manager for multiple FastMCP instances?
  3. Could the framework handle nested lifespans differently for this use case?
  4. Is there a recommended pattern for this scenario that I'm missing?

Use Case

This is important for microservice architectures where different MCP servers handle different domains (e.g., one for user management tools, another for data processing tools) but need to be exposed through a single API gateway.

flobaader avatar May 24 '25 10:05 flobaader

In case you're currently blocked, you can solve by writing an async contextmanager that opens both lifespans and pass that as the lifespan - on the one hand I really don't want FastMCP to have to introduce this because it's largely due to implementation details in the low-level SDK but on the other this is a very sharp edge and if it won't be solved there (I don't expect it to) I think we have to give users an easy way to deal with it!

jlowin avatar May 24 '25 11:05 jlowin

As a workaround you can use

import contextlib

def combine_lifespans(*lifespans):
    # Create a combined lifespan to manage multiple session managers
    @contextlib.asynccontextmanager
    async def combined_lifespan(app):
        async with contextlib.AsyncExitStack() as stack:
            for lifespan in lifespans:
                await stack.enter_async_context(lifespan(app))
            yield

    return combined_lifespan

...
# lifespan=combined_lifespans(app1.lifespan, app2.lifespan)
...

I forget where I found this snippet, but it works well.

Sillocan avatar Jun 01 '25 01:06 Sillocan

Hi y'all, I tried a combined lifespan handler as above @Sillocan and I'm using SSE. However, I still get a lot of errors, namely:

exceptiongroup.ExceptionGroup: unhandled errors in a TaskGroup (1 sub-exception)
...
RuntimeError: Received request before initialization was complete

Happy to provide a MWE, but I'm having difficulty figuring out where this error is coming from and the stack trace is enormous.

DbCrWk avatar Jun 03 '25 18:06 DbCrWk

As a workaround you can use

import contextlib

def combine_lifespans(*lifespans): # Create a combined lifespan to manage multiple session managers @contextlib.asynccontextmanager async def combined_lifespan(app): async with contextlib.AsyncExitStack() as stack: for lifespan in lifespans: await stack.enter_async_context(lifespan(app)) yield

return combined_lifespan

...

lifespan=combined_lifespans(app1.lifespan, app2.lifespan)

... I forget where I found this snippet, but it works well.

thank you @Sillocan , you saved me 👏

Image

thanhENC avatar Jun 04 '25 17:06 thanhENC

I forget where I found this snippet, but it works well.

I suggest documenting this in https://gofastmcp.com/deployment/asgi#fastapi-integration - right now it only says to pass the lifespan context from FastMCP to FastAPI but a typical FastAPI app would already have some lifespan context manager. It was obvious to me that using AsyncExitStack was the way to go about it, but I couldn't get it to work until I saw this snippet.

shevron avatar Jul 03 '25 09:07 shevron