Jeremiah Lowin commited on
Commit
908b68a
·
2 Parent(s): f3513ae3783370

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 start_server():
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(start_server())
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", "mcp"])
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(["mcp", "run", file_spec])
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 lifespan(app) as context:
 
 
 
 
 
 
 
 
 
 
 
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) if lifespan else default_lifespan, # type: ignore
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(self, prefix: str, app: "FastMCP") -> None:
 
 
 
 
 
 
 
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 "weather/get_weather"
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 "weather/weather_prompt"
 
 
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 with / delimiter
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 with + delimiter
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 with / delimiter
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"Imported tool: {name} as {prefixed_name}")
 
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 "sub/sub_tool" in main_app._tool_manager._tools
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["sub/sub_tool"]
24
- assert tool.name == "sub/sub_tool"
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 "weather/get_forecast" in main_app._tool_manager._tools
50
- assert "news/get_headlines" in main_app._tool_manager._tools
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 "api/first_tool" in main_app._tool_manager._tools
72
 
73
  # Mount second app to same prefix
74
  main_app.mount("api", second_app)
75
 
76
  # Verify second tool is there
77
- assert "api/second_tool" in main_app._tool_manager._tools
78
 
79
  # Tools from both mounts are combined
80
- assert "api/first_tool" in main_app._tool_manager._tools
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 "assistant/greeting" in main_app._prompt_manager._prompts
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 "python/review_python" in main_app._prompt_manager._prompts
184
- assert "sql/explain_sql" in main_app._prompt_manager._prompts
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ ]