Merge branch 'main' into docs

This commit is contained in:
Jeremiah Lowin 2025-04-13 10:24:10 -04:00
commit 4f58d826c6
7 changed files with 136 additions and 46 deletions

View file

@ -64,10 +64,11 @@ def check_app_status() -> dict[str, str]:
# Mount sub-applications
app.mount("weather", weather_app)
app.mount("news", news_app)
async def start_server():
async def get_server_details():
"""Print information about mounted resources."""
# Print available tools
tools = app._tool_manager.list_tools()
@ -105,7 +106,7 @@ async def start_server():
if __name__ == "__main__":
# First run our async function to display info
asyncio.run(start_server())
asyncio.run(get_server_details())
# Then start the server (uncomment to run the server)
# app.run()

View file

@ -65,7 +65,7 @@ def _build_uv_command(
"""Build the uv run command that runs a MCP server through mcp run."""
cmd = ["uv"]
cmd.extend(["run", "--with", "mcp"])
cmd.extend(["run", "--with", "fastmcp"])
if with_editable:
cmd.extend(["--with-editable", str(with_editable)])
@ -76,7 +76,7 @@ def _build_uv_command(
cmd.extend(["--with", pkg])
# Add mcp run command
cmd.extend(["mcp", "run", file_spec])
cmd.extend(["fastmcp", "run", file_spec])
return cmd

View file

@ -85,8 +85,6 @@ class PromptManager:
new_prompt = prompt.copy(updates=dict(name=prefixed_name))
# Log the import
logger.debug(f"Importing prompt with name {name} as {prefixed_name}")
# Store the prompt with the prefixed name
self.add_prompt(new_prompt)
logger.debug(f'Imported prompt "{name}" as "{prefixed_name}"')

View file

@ -156,11 +156,9 @@ class ResourceManager:
new_resource = resource.copy(updates=dict(uri=prefixed_uri))
# Log the import
logger.debug(f"Importing resource with URI {uri} as {prefixed_uri}")
# Store directly in resources dictionary
self.add_resource(new_resource)
logger.debug(f'Imported resource "{uri}" as "{prefixed_uri}"')
def import_templates(
self, manager: "ResourceManager", prefix: str | None = None
@ -188,10 +186,8 @@ class ResourceManager:
updates=dict(uri_template=prefixed_uri_template)
)
# Log the import
logger.debug(
f"Importing resource template with URI {uri_template} as {prefixed_uri_template}"
)
# Store directly in templates dictionary
self.add_template(new_template)
logger.debug(
f'Imported template "{uri_template}" as "{prefixed_uri_template}"'
)

View file

@ -6,6 +6,7 @@ import re
from collections.abc import AsyncIterator, Callable
from contextlib import (
AbstractAsyncContextManager,
AsyncExitStack,
asynccontextmanager,
)
from typing import TYPE_CHECKING, Any, Generic, Literal
@ -18,7 +19,6 @@ from fastapi import FastAPI
from mcp.server.lowlevel.helper_types import ReadResourceContents
from mcp.server.lowlevel.server import LifespanResultT
from mcp.server.lowlevel.server import Server as MCPServer
from mcp.server.lowlevel.server import lifespan as default_lifespan
from mcp.server.session import ServerSession
from mcp.server.sse import SseServerTransport
from mcp.server.stdio import stdio_server
@ -56,6 +56,19 @@ if TYPE_CHECKING:
logger = get_logger(__name__)
@asynccontextmanager
async def default_lifespan(server: "FastMCP") -> AsyncIterator[Any]:
"""Default lifespan context manager that does nothing.
Args:
server: The server instance this lifespan is managing
Returns:
An empty context object
"""
yield {}
def lifespan_wrapper(
app: "FastMCP",
lifespan: Callable[["FastMCP"], AbstractAsyncContextManager[LifespanResultT]],
@ -64,7 +77,18 @@ def lifespan_wrapper(
]:
@asynccontextmanager
async def wrap(s: MCPServer[LifespanResultT]) -> AsyncIterator[LifespanResultT]:
async with lifespan(app) as context:
async with AsyncExitStack() as stack:
# enter main app's lifespan
context = await stack.enter_async_context(lifespan(app))
# Enter all mounted app lifespans
for prefix, mounted_app in app._mounted_apps.items():
mounted_context = mounted_app._mcp_server.lifespan(
mounted_app._mcp_server
)
await stack.enter_async_context(mounted_context)
logger.debug(f"Prepared lifespan for mounted app '{prefix}'")
yield context
return wrap
@ -84,10 +108,16 @@ class FastMCP(Generic[LifespanResultT]):
self.tags: set[str] = tags or set()
self.settings = fastmcp.settings.ServerSettings(**settings)
# Setup for mounted apps - must be initialized before _mcp_server
self._mounted_apps: dict[str, FastMCP] = {}
if lifespan is None:
lifespan = default_lifespan
self._mcp_server = MCPServer[LifespanResultT](
name=name or "FastMCP",
instructions=instructions,
lifespan=lifespan_wrapper(self, lifespan) if lifespan else default_lifespan, # type: ignore
lifespan=lifespan_wrapper(self, lifespan),
)
self._tool_manager = ToolManager(
duplicate_behavior=self.settings.on_duplicate_tools
@ -100,9 +130,6 @@ class FastMCP(Generic[LifespanResultT]):
)
self.dependencies = self.settings.dependencies
# Setup for mounted apps
self._mounted_apps: dict[str, FastMCP] = {}
# Set up MCP protocol handlers
self._setup_handlers()
@ -154,6 +181,7 @@ class FastMCP(Generic[LifespanResultT]):
async def list_tools(self) -> list[MCPTool]:
"""List all available tools."""
tools = self._tool_manager.list_tools()
return [
MCPTool(
@ -535,37 +563,56 @@ class FastMCP(Generic[LifespanResultT]):
logger.error(f"Error getting prompt {name}: {e}")
raise ValueError(str(e))
def mount(self, prefix: str, app: "FastMCP") -> None:
def mount(
self,
prefix: str,
app: "FastMCP",
tool_separator: str | None = None,
resource_separator: str | None = None,
prompt_separator: str | None = None,
) -> None:
"""Mount another FastMCP application with a given prefix.
When an application is mounted:
- The tools are imported with prefixed names
Example: If app has a tool named "get_weather", it will be available as "weather/get_weather"
- The resources are imported with prefixed URIs
- The tools are imported with prefixed names using the tool_separator
Example: If app has a tool named "get_weather", it will be available as "weatherget_weather"
- The resources are imported with prefixed URIs using the resource_separator
Example: If app has a resource with URI "weather://forecast", it will be available as "weather+weather://forecast"
- The templates are imported with prefixed URI templates
- The templates are imported with prefixed URI templates using the resource_separator
Example: If app has a template with URI "weather://location/{id}", it will be available as "weather+weather://location/{id}"
- The prompts are imported with prefixed names
Example: If app has a prompt named "weather_prompt", it will be available as "weather/weather_prompt"
- The prompts are imported with prefixed names using the prompt_separator
Example: If app has a prompt named "weather_prompt", it will be available as "weather_weather_prompt"
- The mounted app's lifespan will be executed when the parent app's lifespan runs,
ensuring that any setup needed by the mounted app is performed
Args:
prefix: The prefix to use for the mounted application
app: The FastMCP application to mount
tool_separator: Separator for tool names (defaults to "_")
resource_separator: Separator for resource URIs (defaults to "+")
prompt_separator: Separator for prompt names (defaults to "_")
"""
if tool_separator is None:
tool_separator = "_"
if resource_separator is None:
resource_separator = "+"
if prompt_separator is None:
prompt_separator = "_"
# Mount the app in the list of mounted apps
self._mounted_apps[prefix] = app
# Import tools from the mounted app with / delimiter
tool_prefix = f"{prefix}/"
# Import tools from the mounted app
tool_prefix = f"{prefix}{tool_separator}"
self._tool_manager.import_tools(app._tool_manager, tool_prefix)
# Import resources and templates from the mounted app with + delimiter
resource_prefix = f"{prefix}+"
# Import resources and templates from the mounted app
resource_prefix = f"{prefix}{resource_separator}"
self._resource_manager.import_resources(app._resource_manager, resource_prefix)
self._resource_manager.import_templates(app._resource_manager, resource_prefix)
# Import prompts with / delimiter
prompt_prefix = f"{prefix}/"
# Import prompts from the mounted app
prompt_prefix = f"{prefix}{prompt_separator}"
self._prompt_manager.import_prompts(app._prompt_manager, prompt_prefix)
logger.info(f"Mounted app with prefix '{prefix}'")

View file

@ -93,4 +93,4 @@ class ToolManager:
new_tool = tool.copy(updates=dict(name=prefixed_name))
# Store the copied tool
self.add_tool(new_tool)
logger.debug(f"Imported tool: {name} as {prefixed_name}")
logger.debug(f'Imported tool "{name}" as "{prefixed_name}"')

View file

@ -1,3 +1,7 @@
import contextlib
import pytest
from fastmcp.server.server import FastMCP
@ -16,12 +20,12 @@ async def test_mount_basic_functionality():
main_app.mount("sub", sub_app)
# Verify the tool was imported with the prefix
assert "sub/sub_tool" in main_app._tool_manager._tools
assert "sub_sub_tool" in main_app._tool_manager._tools
assert "sub_tool" in sub_app._tool_manager._tools
# Verify the original tool still exists in the sub-app
tool = main_app._tool_manager._tools["sub/sub_tool"]
assert tool.name == "sub/sub_tool"
tool = main_app._tool_manager._tools["sub_sub_tool"]
assert tool.name == "sub_sub_tool"
assert callable(tool.fn)
@ -46,8 +50,8 @@ async def test_mount_multiple_apps():
main_app.mount("news", news_app)
# Verify tools were imported with the correct prefixes
assert "weather/get_forecast" in main_app._tool_manager._tools
assert "news/get_headlines" in main_app._tool_manager._tools
assert "weather_get_forecast" in main_app._tool_manager._tools
assert "news_get_headlines" in main_app._tool_manager._tools
async def test_mount_combines_tools():
@ -68,16 +72,16 @@ async def test_mount_combines_tools():
# Mount first app
main_app.mount("api", first_app)
assert "api/first_tool" in main_app._tool_manager._tools
assert "api_first_tool" in main_app._tool_manager._tools
# Mount second app to same prefix
main_app.mount("api", second_app)
# Verify second tool is there
assert "api/second_tool" in main_app._tool_manager._tools
assert "api_second_tool" in main_app._tool_manager._tools
# Tools from both mounts are combined
assert "api/first_tool" in main_app._tool_manager._tools
assert "api_first_tool" in main_app._tool_manager._tools
async def test_mount_with_resources():
@ -131,7 +135,7 @@ async def test_mount_with_prompts():
main_app.mount("assistant", assistant_app)
# Verify the prompt was imported with the prefix
assert "assistant/greeting" in main_app._prompt_manager._prompts
assert "assistant_greeting" in main_app._prompt_manager._prompts
async def test_mount_multiple_resource_templates():
@ -180,5 +184,49 @@ async def test_mount_multiple_prompts():
main_app.mount("sql", sql_app)
# Verify prompts were imported with correct prefixes
assert "python/review_python" in main_app._prompt_manager._prompts
assert "sql/explain_sql" in main_app._prompt_manager._prompts
assert "python_review_python" in main_app._prompt_manager._prompts
assert "sql_explain_sql" in main_app._prompt_manager._prompts
@pytest.mark.anyio
async def test_mount_lifespan():
"""Test that the lifespan of a mounted app is properly handled."""
# Create apps
lifespan_checkpoints = []
@contextlib.asynccontextmanager
async def lifespan(app: FastMCP):
lifespan_checkpoints.append(f"enter {app.name}")
try:
yield
finally:
lifespan_checkpoints.append(f"exit {app.name}")
main_app = FastMCP("MainApp", lifespan=lifespan)
sub_app = FastMCP("SubApp", lifespan=lifespan)
sub_app_2 = FastMCP("SubApp2", lifespan=lifespan)
main_app.mount("sub", sub_app)
main_app.mount("sub2", sub_app_2)
low_level_server = main_app._mcp_server
async with contextlib.AsyncExitStack() as stack:
# Note: this imitates the way that lifespans are entered for mounted
# apps It is presently difficult to stop a running server
# programmatically without error in order to test the exit conditions,
# so this is the next best thing
await stack.enter_async_context(low_level_server.lifespan(low_level_server))
assert lifespan_checkpoints == [
"enter MainApp",
"enter SubApp",
"enter SubApp2",
]
assert lifespan_checkpoints == [
"enter MainApp",
"enter SubApp",
"enter SubApp2",
"exit SubApp2",
"exit SubApp",
"exit MainApp",
]