Skip to content

Commit 0972758

Browse files
authored
Merge pull request #129 from jlowin/lifespan
Enter mounted app lifespans
2 parents 6c4d86f + 218c49c commit 0972758

File tree

2 files changed

+86
-6
lines changed

2 files changed

+86
-6
lines changed

Diff for: src/fastmcp/server/server.py

+38-6
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from collections.abc import AsyncIterator, Callable
77
from contextlib import (
88
AbstractAsyncContextManager,
9+
AsyncExitStack,
910
asynccontextmanager,
1011
)
1112
from typing import TYPE_CHECKING, Any, Generic, Literal
@@ -18,7 +19,6 @@
1819
from mcp.server.lowlevel.helper_types import ReadResourceContents
1920
from mcp.server.lowlevel.server import LifespanResultT
2021
from mcp.server.lowlevel.server import Server as MCPServer
21-
from mcp.server.lowlevel.server import lifespan as default_lifespan
2222
from mcp.server.session import ServerSession
2323
from mcp.server.sse import SseServerTransport
2424
from mcp.server.stdio import stdio_server
@@ -56,6 +56,19 @@
5656
logger = get_logger(__name__)
5757

5858

59+
@asynccontextmanager
60+
async def default_lifespan(server: "FastMCP") -> AsyncIterator[Any]:
61+
"""Default lifespan context manager that does nothing.
62+
63+
Args:
64+
server: The server instance this lifespan is managing
65+
66+
Returns:
67+
An empty context object
68+
"""
69+
yield {}
70+
71+
5972
def lifespan_wrapper(
6073
app: "FastMCP",
6174
lifespan: Callable[["FastMCP"], AbstractAsyncContextManager[LifespanResultT]],
@@ -64,7 +77,18 @@ def lifespan_wrapper(
6477
]:
6578
@asynccontextmanager
6679
async def wrap(s: MCPServer[LifespanResultT]) -> AsyncIterator[LifespanResultT]:
67-
async with lifespan(app) as context:
80+
async with AsyncExitStack() as stack:
81+
# enter main app's lifespan
82+
context = await stack.enter_async_context(lifespan(app))
83+
84+
# Enter all mounted app lifespans
85+
for prefix, mounted_app in app._mounted_apps.items():
86+
mounted_context = mounted_app._mcp_server.lifespan(
87+
mounted_app._mcp_server
88+
)
89+
await stack.enter_async_context(mounted_context)
90+
logger.debug(f"Prepared lifespan for mounted app '{prefix}'")
91+
6892
yield context
6993

7094
return wrap
@@ -84,10 +108,16 @@ def __init__(
84108
self.tags: set[str] = tags or set()
85109
self.settings = fastmcp.settings.ServerSettings(**settings)
86110

111+
# Setup for mounted apps - must be initialized before _mcp_server
112+
self._mounted_apps: dict[str, FastMCP] = {}
113+
114+
if lifespan is None:
115+
lifespan = default_lifespan
116+
87117
self._mcp_server = MCPServer[LifespanResultT](
88118
name=name or "FastMCP",
89119
instructions=instructions,
90-
lifespan=lifespan_wrapper(self, lifespan) if lifespan else default_lifespan, # type: ignore
120+
lifespan=lifespan_wrapper(self, lifespan),
91121
)
92122
self._tool_manager = ToolManager(
93123
duplicate_behavior=self.settings.on_duplicate_tools
@@ -100,9 +130,6 @@ def __init__(
100130
)
101131
self.dependencies = self.settings.dependencies
102132

103-
# Setup for mounted apps
104-
self._mounted_apps: dict[str, FastMCP] = {}
105-
106133
# Set up MCP protocol handlers
107134
self._setup_handlers()
108135

@@ -554,10 +581,15 @@ def mount(
554581
Example: If app has a template with URI "weather://location/{id}", it will be available as "weather+weather://location/{id}"
555582
- The prompts are imported with prefixed names using the prompt_separator
556583
Example: If app has a prompt named "weather_prompt", it will be available as "weather_weather_prompt"
584+
- The mounted app's lifespan will be executed when the parent app's lifespan runs,
585+
ensuring that any setup needed by the mounted app is performed
557586
558587
Args:
559588
prefix: The prefix to use for the mounted application
560589
app: The FastMCP application to mount
590+
tool_separator: Separator for tool names (defaults to "_")
591+
resource_separator: Separator for resource URIs (defaults to "+")
592+
prompt_separator: Separator for prompt names (defaults to "_")
561593
"""
562594
if tool_separator is None:
563595
tool_separator = "_"

Diff for: tests/server/test_mount.py

+48
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import contextlib
2+
3+
import pytest
4+
15
from fastmcp.server.server import FastMCP
26

37

@@ -182,3 +186,47 @@ def explain_sql(query: str) -> str:
182186
# Verify prompts were imported with correct prefixes
183187
assert "python_review_python" in main_app._prompt_manager._prompts
184188
assert "sql_explain_sql" in main_app._prompt_manager._prompts
189+
190+
191+
@pytest.mark.anyio
192+
async def test_mount_lifespan():
193+
"""Test that the lifespan of a mounted app is properly handled."""
194+
# Create apps
195+
196+
lifespan_checkpoints = []
197+
198+
@contextlib.asynccontextmanager
199+
async def lifespan(app: FastMCP):
200+
lifespan_checkpoints.append(f"enter {app.name}")
201+
try:
202+
yield
203+
finally:
204+
lifespan_checkpoints.append(f"exit {app.name}")
205+
206+
main_app = FastMCP("MainApp", lifespan=lifespan)
207+
sub_app = FastMCP("SubApp", lifespan=lifespan)
208+
sub_app_2 = FastMCP("SubApp2", lifespan=lifespan)
209+
210+
main_app.mount("sub", sub_app)
211+
main_app.mount("sub2", sub_app_2)
212+
213+
low_level_server = main_app._mcp_server
214+
async with contextlib.AsyncExitStack() as stack:
215+
# Note: this imitates the way that lifespans are entered for mounted
216+
# apps It is presently difficult to stop a running server
217+
# programmatically without error in order to test the exit conditions,
218+
# so this is the next best thing
219+
await stack.enter_async_context(low_level_server.lifespan(low_level_server))
220+
assert lifespan_checkpoints == [
221+
"enter MainApp",
222+
"enter SubApp",
223+
"enter SubApp2",
224+
]
225+
assert lifespan_checkpoints == [
226+
"enter MainApp",
227+
"enter SubApp",
228+
"enter SubApp2",
229+
"exit SubApp2",
230+
"exit SubApp",
231+
"exit MainApp",
232+
]

0 commit comments

Comments
 (0)