Spaces:
Running
Running
Merge branch 'main' into docs
Browse files
examples/mount_example.py
CHANGED
|
@@ -64,10 +64,11 @@ def check_app_status() -> dict[str, str]:
|
|
| 64 |
|
| 65 |
# Mount sub-applications
|
| 66 |
app.mount("weather", weather_app)
|
|
|
|
| 67 |
app.mount("news", news_app)
|
| 68 |
|
| 69 |
|
| 70 |
-
async def
|
| 71 |
"""Print information about mounted resources."""
|
| 72 |
# Print available tools
|
| 73 |
tools = app._tool_manager.list_tools()
|
|
@@ -105,7 +106,7 @@ async def start_server():
|
|
| 105 |
|
| 106 |
if __name__ == "__main__":
|
| 107 |
# First run our async function to display info
|
| 108 |
-
asyncio.run(
|
| 109 |
|
| 110 |
# Then start the server (uncomment to run the server)
|
| 111 |
# app.run()
|
|
|
|
| 64 |
|
| 65 |
# Mount sub-applications
|
| 66 |
app.mount("weather", weather_app)
|
| 67 |
+
|
| 68 |
app.mount("news", news_app)
|
| 69 |
|
| 70 |
|
| 71 |
+
async def get_server_details():
|
| 72 |
"""Print information about mounted resources."""
|
| 73 |
# Print available tools
|
| 74 |
tools = app._tool_manager.list_tools()
|
|
|
|
| 106 |
|
| 107 |
if __name__ == "__main__":
|
| 108 |
# First run our async function to display info
|
| 109 |
+
asyncio.run(get_server_details())
|
| 110 |
|
| 111 |
# Then start the server (uncomment to run the server)
|
| 112 |
# app.run()
|
src/fastmcp/cli/cli.py
CHANGED
|
@@ -65,7 +65,7 @@ def _build_uv_command(
|
|
| 65 |
"""Build the uv run command that runs a MCP server through mcp run."""
|
| 66 |
cmd = ["uv"]
|
| 67 |
|
| 68 |
-
cmd.extend(["run", "--with", "
|
| 69 |
|
| 70 |
if with_editable:
|
| 71 |
cmd.extend(["--with-editable", str(with_editable)])
|
|
@@ -76,7 +76,7 @@ def _build_uv_command(
|
|
| 76 |
cmd.extend(["--with", pkg])
|
| 77 |
|
| 78 |
# Add mcp run command
|
| 79 |
-
cmd.extend(["
|
| 80 |
return cmd
|
| 81 |
|
| 82 |
|
|
|
|
| 65 |
"""Build the uv run command that runs a MCP server through mcp run."""
|
| 66 |
cmd = ["uv"]
|
| 67 |
|
| 68 |
+
cmd.extend(["run", "--with", "fastmcp"])
|
| 69 |
|
| 70 |
if with_editable:
|
| 71 |
cmd.extend(["--with-editable", str(with_editable)])
|
|
|
|
| 76 |
cmd.extend(["--with", pkg])
|
| 77 |
|
| 78 |
# Add mcp run command
|
| 79 |
+
cmd.extend(["fastmcp", "run", file_spec])
|
| 80 |
return cmd
|
| 81 |
|
| 82 |
|
src/fastmcp/prompts/prompt_manager.py
CHANGED
|
@@ -85,8 +85,6 @@ class PromptManager:
|
|
| 85 |
|
| 86 |
new_prompt = prompt.copy(updates=dict(name=prefixed_name))
|
| 87 |
|
| 88 |
-
# Log the import
|
| 89 |
-
logger.debug(f"Importing prompt with name {name} as {prefixed_name}")
|
| 90 |
-
|
| 91 |
# Store the prompt with the prefixed name
|
| 92 |
self.add_prompt(new_prompt)
|
|
|
|
|
|
| 85 |
|
| 86 |
new_prompt = prompt.copy(updates=dict(name=prefixed_name))
|
| 87 |
|
|
|
|
|
|
|
|
|
|
| 88 |
# Store the prompt with the prefixed name
|
| 89 |
self.add_prompt(new_prompt)
|
| 90 |
+
logger.debug(f'Imported prompt "{name}" as "{prefixed_name}"')
|
src/fastmcp/resources/resource_manager.py
CHANGED
|
@@ -156,11 +156,9 @@ class ResourceManager:
|
|
| 156 |
|
| 157 |
new_resource = resource.copy(updates=dict(uri=prefixed_uri))
|
| 158 |
|
| 159 |
-
# Log the import
|
| 160 |
-
logger.debug(f"Importing resource with URI {uri} as {prefixed_uri}")
|
| 161 |
-
|
| 162 |
# Store directly in resources dictionary
|
| 163 |
self.add_resource(new_resource)
|
|
|
|
| 164 |
|
| 165 |
def import_templates(
|
| 166 |
self, manager: "ResourceManager", prefix: str | None = None
|
|
@@ -188,10 +186,8 @@ class ResourceManager:
|
|
| 188 |
updates=dict(uri_template=prefixed_uri_template)
|
| 189 |
)
|
| 190 |
|
| 191 |
-
# Log the import
|
| 192 |
-
logger.debug(
|
| 193 |
-
f"Importing resource template with URI {uri_template} as {prefixed_uri_template}"
|
| 194 |
-
)
|
| 195 |
-
|
| 196 |
# Store directly in templates dictionary
|
| 197 |
self.add_template(new_template)
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
|
| 157 |
new_resource = resource.copy(updates=dict(uri=prefixed_uri))
|
| 158 |
|
|
|
|
|
|
|
|
|
|
| 159 |
# Store directly in resources dictionary
|
| 160 |
self.add_resource(new_resource)
|
| 161 |
+
logger.debug(f'Imported resource "{uri}" as "{prefixed_uri}"')
|
| 162 |
|
| 163 |
def import_templates(
|
| 164 |
self, manager: "ResourceManager", prefix: str | None = None
|
|
|
|
| 186 |
updates=dict(uri_template=prefixed_uri_template)
|
| 187 |
)
|
| 188 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
# Store directly in templates dictionary
|
| 190 |
self.add_template(new_template)
|
| 191 |
+
logger.debug(
|
| 192 |
+
f'Imported template "{uri_template}" as "{prefixed_uri_template}"'
|
| 193 |
+
)
|
src/fastmcp/server/server.py
CHANGED
|
@@ -6,6 +6,7 @@ import re
|
|
| 6 |
from collections.abc import AsyncIterator, Callable
|
| 7 |
from contextlib import (
|
| 8 |
AbstractAsyncContextManager,
|
|
|
|
| 9 |
asynccontextmanager,
|
| 10 |
)
|
| 11 |
from typing import TYPE_CHECKING, Any, Generic, Literal
|
|
@@ -18,7 +19,6 @@ from fastapi import FastAPI
|
|
| 18 |
from mcp.server.lowlevel.helper_types import ReadResourceContents
|
| 19 |
from mcp.server.lowlevel.server import LifespanResultT
|
| 20 |
from mcp.server.lowlevel.server import Server as MCPServer
|
| 21 |
-
from mcp.server.lowlevel.server import lifespan as default_lifespan
|
| 22 |
from mcp.server.session import ServerSession
|
| 23 |
from mcp.server.sse import SseServerTransport
|
| 24 |
from mcp.server.stdio import stdio_server
|
|
@@ -56,6 +56,19 @@ if TYPE_CHECKING:
|
|
| 56 |
logger = get_logger(__name__)
|
| 57 |
|
| 58 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
def lifespan_wrapper(
|
| 60 |
app: "FastMCP",
|
| 61 |
lifespan: Callable[["FastMCP"], AbstractAsyncContextManager[LifespanResultT]],
|
|
@@ -64,7 +77,18 @@ def lifespan_wrapper(
|
|
| 64 |
]:
|
| 65 |
@asynccontextmanager
|
| 66 |
async def wrap(s: MCPServer[LifespanResultT]) -> AsyncIterator[LifespanResultT]:
|
| 67 |
-
async with
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
yield context
|
| 69 |
|
| 70 |
return wrap
|
|
@@ -84,10 +108,16 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 84 |
self.tags: set[str] = tags or set()
|
| 85 |
self.settings = fastmcp.settings.ServerSettings(**settings)
|
| 86 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
self._mcp_server = MCPServer[LifespanResultT](
|
| 88 |
name=name or "FastMCP",
|
| 89 |
instructions=instructions,
|
| 90 |
-
lifespan=lifespan_wrapper(self, lifespan)
|
| 91 |
)
|
| 92 |
self._tool_manager = ToolManager(
|
| 93 |
duplicate_behavior=self.settings.on_duplicate_tools
|
|
@@ -100,9 +130,6 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 100 |
)
|
| 101 |
self.dependencies = self.settings.dependencies
|
| 102 |
|
| 103 |
-
# Setup for mounted apps
|
| 104 |
-
self._mounted_apps: dict[str, FastMCP] = {}
|
| 105 |
-
|
| 106 |
# Set up MCP protocol handlers
|
| 107 |
self._setup_handlers()
|
| 108 |
|
|
@@ -154,6 +181,7 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 154 |
|
| 155 |
async def list_tools(self) -> list[MCPTool]:
|
| 156 |
"""List all available tools."""
|
|
|
|
| 157 |
tools = self._tool_manager.list_tools()
|
| 158 |
return [
|
| 159 |
MCPTool(
|
|
@@ -535,37 +563,56 @@ class FastMCP(Generic[LifespanResultT]):
|
|
| 535 |
logger.error(f"Error getting prompt {name}: {e}")
|
| 536 |
raise ValueError(str(e))
|
| 537 |
|
| 538 |
-
def mount(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 539 |
"""Mount another FastMCP application with a given prefix.
|
| 540 |
|
| 541 |
When an application is mounted:
|
| 542 |
-
- The tools are imported with prefixed names
|
| 543 |
-
Example: If app has a tool named "get_weather", it will be available as "
|
| 544 |
-
- The resources are imported with prefixed URIs
|
| 545 |
Example: If app has a resource with URI "weather://forecast", it will be available as "weather+weather://forecast"
|
| 546 |
-
- The templates are imported with prefixed URI templates
|
| 547 |
Example: If app has a template with URI "weather://location/{id}", it will be available as "weather+weather://location/{id}"
|
| 548 |
-
- The prompts are imported with prefixed names
|
| 549 |
-
Example: If app has a prompt named "weather_prompt", it will be available as "
|
|
|
|
|
|
|
| 550 |
|
| 551 |
Args:
|
| 552 |
prefix: The prefix to use for the mounted application
|
| 553 |
app: The FastMCP application to mount
|
|
|
|
|
|
|
|
|
|
| 554 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 555 |
# Mount the app in the list of mounted apps
|
| 556 |
self._mounted_apps[prefix] = app
|
| 557 |
|
| 558 |
-
# Import tools from the mounted app
|
| 559 |
-
tool_prefix = f"{prefix}
|
| 560 |
self._tool_manager.import_tools(app._tool_manager, tool_prefix)
|
| 561 |
|
| 562 |
-
# Import resources and templates from the mounted app
|
| 563 |
-
resource_prefix = f"{prefix}
|
| 564 |
self._resource_manager.import_resources(app._resource_manager, resource_prefix)
|
| 565 |
self._resource_manager.import_templates(app._resource_manager, resource_prefix)
|
| 566 |
|
| 567 |
-
# Import prompts
|
| 568 |
-
prompt_prefix = f"{prefix}
|
| 569 |
self._prompt_manager.import_prompts(app._prompt_manager, prompt_prefix)
|
| 570 |
|
| 571 |
logger.info(f"Mounted app with prefix '{prefix}'")
|
|
|
|
| 6 |
from collections.abc import AsyncIterator, Callable
|
| 7 |
from contextlib import (
|
| 8 |
AbstractAsyncContextManager,
|
| 9 |
+
AsyncExitStack,
|
| 10 |
asynccontextmanager,
|
| 11 |
)
|
| 12 |
from typing import TYPE_CHECKING, Any, Generic, Literal
|
|
|
|
| 19 |
from mcp.server.lowlevel.helper_types import ReadResourceContents
|
| 20 |
from mcp.server.lowlevel.server import LifespanResultT
|
| 21 |
from mcp.server.lowlevel.server import Server as MCPServer
|
|
|
|
| 22 |
from mcp.server.session import ServerSession
|
| 23 |
from mcp.server.sse import SseServerTransport
|
| 24 |
from mcp.server.stdio import stdio_server
|
|
|
|
| 56 |
logger = get_logger(__name__)
|
| 57 |
|
| 58 |
|
| 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 |
+
|
| 72 |
def lifespan_wrapper(
|
| 73 |
app: "FastMCP",
|
| 74 |
lifespan: Callable[["FastMCP"], AbstractAsyncContextManager[LifespanResultT]],
|
|
|
|
| 77 |
]:
|
| 78 |
@asynccontextmanager
|
| 79 |
async def wrap(s: MCPServer[LifespanResultT]) -> AsyncIterator[LifespanResultT]:
|
| 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 |
+
|
| 92 |
yield context
|
| 93 |
|
| 94 |
return wrap
|
|
|
|
| 108 |
self.tags: set[str] = tags or set()
|
| 109 |
self.settings = fastmcp.settings.ServerSettings(**settings)
|
| 110 |
|
| 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 |
+
|
| 117 |
self._mcp_server = MCPServer[LifespanResultT](
|
| 118 |
name=name or "FastMCP",
|
| 119 |
instructions=instructions,
|
| 120 |
+
lifespan=lifespan_wrapper(self, lifespan),
|
| 121 |
)
|
| 122 |
self._tool_manager = ToolManager(
|
| 123 |
duplicate_behavior=self.settings.on_duplicate_tools
|
|
|
|
| 130 |
)
|
| 131 |
self.dependencies = self.settings.dependencies
|
| 132 |
|
|
|
|
|
|
|
|
|
|
| 133 |
# Set up MCP protocol handlers
|
| 134 |
self._setup_handlers()
|
| 135 |
|
|
|
|
| 181 |
|
| 182 |
async def list_tools(self) -> list[MCPTool]:
|
| 183 |
"""List all available tools."""
|
| 184 |
+
|
| 185 |
tools = self._tool_manager.list_tools()
|
| 186 |
return [
|
| 187 |
MCPTool(
|
|
|
|
| 563 |
logger.error(f"Error getting prompt {name}: {e}")
|
| 564 |
raise ValueError(str(e))
|
| 565 |
|
| 566 |
+
def mount(
|
| 567 |
+
self,
|
| 568 |
+
prefix: str,
|
| 569 |
+
app: "FastMCP",
|
| 570 |
+
tool_separator: str | None = None,
|
| 571 |
+
resource_separator: str | None = None,
|
| 572 |
+
prompt_separator: str | None = None,
|
| 573 |
+
) -> None:
|
| 574 |
"""Mount another FastMCP application with a given prefix.
|
| 575 |
|
| 576 |
When an application is mounted:
|
| 577 |
+
- The tools are imported with prefixed names using the tool_separator
|
| 578 |
+
Example: If app has a tool named "get_weather", it will be available as "weatherget_weather"
|
| 579 |
+
- The resources are imported with prefixed URIs using the resource_separator
|
| 580 |
Example: If app has a resource with URI "weather://forecast", it will be available as "weather+weather://forecast"
|
| 581 |
+
- The templates are imported with prefixed URI templates using the resource_separator
|
| 582 |
Example: If app has a template with URI "weather://location/{id}", it will be available as "weather+weather://location/{id}"
|
| 583 |
+
- The prompts are imported with prefixed names using the prompt_separator
|
| 584 |
+
Example: If app has a prompt named "weather_prompt", it will be available as "weather_weather_prompt"
|
| 585 |
+
- The mounted app's lifespan will be executed when the parent app's lifespan runs,
|
| 586 |
+
ensuring that any setup needed by the mounted app is performed
|
| 587 |
|
| 588 |
Args:
|
| 589 |
prefix: The prefix to use for the mounted application
|
| 590 |
app: The FastMCP application to mount
|
| 591 |
+
tool_separator: Separator for tool names (defaults to "_")
|
| 592 |
+
resource_separator: Separator for resource URIs (defaults to "+")
|
| 593 |
+
prompt_separator: Separator for prompt names (defaults to "_")
|
| 594 |
"""
|
| 595 |
+
if tool_separator is None:
|
| 596 |
+
tool_separator = "_"
|
| 597 |
+
if resource_separator is None:
|
| 598 |
+
resource_separator = "+"
|
| 599 |
+
if prompt_separator is None:
|
| 600 |
+
prompt_separator = "_"
|
| 601 |
+
|
| 602 |
# Mount the app in the list of mounted apps
|
| 603 |
self._mounted_apps[prefix] = app
|
| 604 |
|
| 605 |
+
# Import tools from the mounted app
|
| 606 |
+
tool_prefix = f"{prefix}{tool_separator}"
|
| 607 |
self._tool_manager.import_tools(app._tool_manager, tool_prefix)
|
| 608 |
|
| 609 |
+
# Import resources and templates from the mounted app
|
| 610 |
+
resource_prefix = f"{prefix}{resource_separator}"
|
| 611 |
self._resource_manager.import_resources(app._resource_manager, resource_prefix)
|
| 612 |
self._resource_manager.import_templates(app._resource_manager, resource_prefix)
|
| 613 |
|
| 614 |
+
# Import prompts from the mounted app
|
| 615 |
+
prompt_prefix = f"{prefix}{prompt_separator}"
|
| 616 |
self._prompt_manager.import_prompts(app._prompt_manager, prompt_prefix)
|
| 617 |
|
| 618 |
logger.info(f"Mounted app with prefix '{prefix}'")
|
src/fastmcp/tools/tool_manager.py
CHANGED
|
@@ -93,4 +93,4 @@ class ToolManager:
|
|
| 93 |
new_tool = tool.copy(updates=dict(name=prefixed_name))
|
| 94 |
# Store the copied tool
|
| 95 |
self.add_tool(new_tool)
|
| 96 |
-
logger.debug(f
|
|
|
|
| 93 |
new_tool = tool.copy(updates=dict(name=prefixed_name))
|
| 94 |
# Store the copied tool
|
| 95 |
self.add_tool(new_tool)
|
| 96 |
+
logger.debug(f'Imported tool "{name}" as "{prefixed_name}"')
|
tests/server/test_mount.py
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from fastmcp.server.server import FastMCP
|
| 2 |
|
| 3 |
|
|
@@ -16,12 +20,12 @@ async def test_mount_basic_functionality():
|
|
| 16 |
main_app.mount("sub", sub_app)
|
| 17 |
|
| 18 |
# Verify the tool was imported with the prefix
|
| 19 |
-
assert "
|
| 20 |
assert "sub_tool" in sub_app._tool_manager._tools
|
| 21 |
|
| 22 |
# Verify the original tool still exists in the sub-app
|
| 23 |
-
tool = main_app._tool_manager._tools["
|
| 24 |
-
assert tool.name == "
|
| 25 |
assert callable(tool.fn)
|
| 26 |
|
| 27 |
|
|
@@ -46,8 +50,8 @@ async def test_mount_multiple_apps():
|
|
| 46 |
main_app.mount("news", news_app)
|
| 47 |
|
| 48 |
# Verify tools were imported with the correct prefixes
|
| 49 |
-
assert "
|
| 50 |
-
assert "
|
| 51 |
|
| 52 |
|
| 53 |
async def test_mount_combines_tools():
|
|
@@ -68,16 +72,16 @@ async def test_mount_combines_tools():
|
|
| 68 |
|
| 69 |
# Mount first app
|
| 70 |
main_app.mount("api", first_app)
|
| 71 |
-
assert "
|
| 72 |
|
| 73 |
# Mount second app to same prefix
|
| 74 |
main_app.mount("api", second_app)
|
| 75 |
|
| 76 |
# Verify second tool is there
|
| 77 |
-
assert "
|
| 78 |
|
| 79 |
# Tools from both mounts are combined
|
| 80 |
-
assert "
|
| 81 |
|
| 82 |
|
| 83 |
async def test_mount_with_resources():
|
|
@@ -131,7 +135,7 @@ async def test_mount_with_prompts():
|
|
| 131 |
main_app.mount("assistant", assistant_app)
|
| 132 |
|
| 133 |
# Verify the prompt was imported with the prefix
|
| 134 |
-
assert "
|
| 135 |
|
| 136 |
|
| 137 |
async def test_mount_multiple_resource_templates():
|
|
@@ -180,5 +184,49 @@ async def test_mount_multiple_prompts():
|
|
| 180 |
main_app.mount("sql", sql_app)
|
| 181 |
|
| 182 |
# Verify prompts were imported with correct prefixes
|
| 183 |
-
assert "
|
| 184 |
-
assert "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import contextlib
|
| 2 |
+
|
| 3 |
+
import pytest
|
| 4 |
+
|
| 5 |
from fastmcp.server.server import FastMCP
|
| 6 |
|
| 7 |
|
|
|
|
| 20 |
main_app.mount("sub", sub_app)
|
| 21 |
|
| 22 |
# Verify the tool was imported with the prefix
|
| 23 |
+
assert "sub_sub_tool" in main_app._tool_manager._tools
|
| 24 |
assert "sub_tool" in sub_app._tool_manager._tools
|
| 25 |
|
| 26 |
# Verify the original tool still exists in the sub-app
|
| 27 |
+
tool = main_app._tool_manager._tools["sub_sub_tool"]
|
| 28 |
+
assert tool.name == "sub_sub_tool"
|
| 29 |
assert callable(tool.fn)
|
| 30 |
|
| 31 |
|
|
|
|
| 50 |
main_app.mount("news", news_app)
|
| 51 |
|
| 52 |
# Verify tools were imported with the correct prefixes
|
| 53 |
+
assert "weather_get_forecast" in main_app._tool_manager._tools
|
| 54 |
+
assert "news_get_headlines" in main_app._tool_manager._tools
|
| 55 |
|
| 56 |
|
| 57 |
async def test_mount_combines_tools():
|
|
|
|
| 72 |
|
| 73 |
# Mount first app
|
| 74 |
main_app.mount("api", first_app)
|
| 75 |
+
assert "api_first_tool" in main_app._tool_manager._tools
|
| 76 |
|
| 77 |
# Mount second app to same prefix
|
| 78 |
main_app.mount("api", second_app)
|
| 79 |
|
| 80 |
# Verify second tool is there
|
| 81 |
+
assert "api_second_tool" in main_app._tool_manager._tools
|
| 82 |
|
| 83 |
# Tools from both mounts are combined
|
| 84 |
+
assert "api_first_tool" in main_app._tool_manager._tools
|
| 85 |
|
| 86 |
|
| 87 |
async def test_mount_with_resources():
|
|
|
|
| 135 |
main_app.mount("assistant", assistant_app)
|
| 136 |
|
| 137 |
# Verify the prompt was imported with the prefix
|
| 138 |
+
assert "assistant_greeting" in main_app._prompt_manager._prompts
|
| 139 |
|
| 140 |
|
| 141 |
async def test_mount_multiple_resource_templates():
|
|
|
|
| 184 |
main_app.mount("sql", sql_app)
|
| 185 |
|
| 186 |
# Verify prompts were imported with correct prefixes
|
| 187 |
+
assert "python_review_python" in main_app._prompt_manager._prompts
|
| 188 |
+
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 |
+
]
|