Jeremiah Lowin commited on
Commit
72f52ef
·
1 Parent(s): 81e8514

Add mount tests

Browse files
src/fastmcp/prompts/prompt_manager.py CHANGED
@@ -77,3 +77,7 @@ class PromptManager:
77
  raise NotFoundError(f"Unknown prompt: {name}")
78
 
79
  return await prompt.render(arguments)
 
 
 
 
 
77
  raise NotFoundError(f"Unknown prompt: {name}")
78
 
79
  return await prompt.render(arguments)
80
+
81
+ def has_prompt(self, key: str) -> bool:
82
+ """Check if a prompt exists."""
83
+ return key in self._prompts
src/fastmcp/resources/resource_manager.py CHANGED
@@ -202,6 +202,16 @@ class ResourceManager:
202
  self._templates[storage_key] = template
203
  return template
204
 
 
 
 
 
 
 
 
 
 
 
205
  async def get_resource(self, uri: AnyUrl | str) -> Resource:
206
  """Get resource by URI, checking concrete resources first, then templates.
207
 
 
202
  self._templates[storage_key] = template
203
  return template
204
 
205
+ def has_resource(self, uri: AnyUrl | str) -> bool:
206
+ """Check if a resource exists."""
207
+ uri_str = str(uri)
208
+ if uri_str in self._resources:
209
+ return True
210
+ for template_key in self._templates.keys():
211
+ if match_uri_template(uri_str, template_key):
212
+ return True
213
+ return False
214
+
215
  async def get_resource(self, uri: AnyUrl | str) -> Resource:
216
  """Get resource by URI, checking concrete resources first, then templates.
217
 
src/fastmcp/server/server.py CHANGED
@@ -67,7 +67,6 @@ class MountedServer:
67
  tool_separator: str | None = None,
68
  resource_separator: str | None = None,
69
  prompt_separator: str | None = None,
70
- cache_expiration_seconds: int = 10,
71
  ):
72
  if tool_separator is None:
73
  tool_separator = "_"
@@ -81,54 +80,53 @@ class MountedServer:
81
  self.tool_separator = tool_separator
82
  self.resource_separator = resource_separator
83
  self.prompt_separator = prompt_separator
84
- self.cache = TimedCache(
85
- expiration=datetime.timedelta(seconds=cache_expiration_seconds)
86
- )
87
 
88
  async def get_tools(self) -> dict[str, Tool]:
89
- cached_tools = self.cache.get("tools")
90
- if cached_tools is NOT_FOUND:
91
- self.cache.set("tools", {})
92
- cached_tools = await self.server.get_tools()
93
- self.cache.set("tools", cached_tools)
94
  return {
95
  f"{self.prefix}{self.tool_separator}{key}": tool
96
- for key, tool in cached_tools.items()
97
  }
98
 
99
  async def get_resources(self) -> dict[str, Resource]:
100
- cached_resources = self.cache.get("resources")
101
- if cached_resources is NOT_FOUND:
102
- self.cache.set("resources", {})
103
- cached_resources = await self.server.get_resources()
104
- self.cache.set("resources", cached_resources)
105
- return cached_resources
106
 
107
  async def get_resource_templates(self) -> dict[str, ResourceTemplate]:
108
- cached_templates = self.cache.get("resource_templates")
109
- if cached_templates is NOT_FOUND:
110
- self.cache.set("resource_templates", {})
111
- cached_templates = await self.server.get_resource_templates()
112
- self.cache.set("resource_templates", cached_templates)
113
- return cached_templates
114
 
115
  async def get_prompts(self) -> dict[str, Prompt]:
116
- cached_prompts = self.cache.get("prompts")
117
- if cached_prompts is NOT_FOUND:
118
- self.cache.set("prompts", {})
119
- cached_prompts = await self.server.get_prompts()
120
- self.cache.set("prompts", cached_prompts)
121
- return cached_prompts
122
-
123
- async def match_tool(self, key: str) -> bool:
124
  return key.startswith(f"{self.prefix}{self.tool_separator}")
125
 
126
- async def match_resource(self, key: str) -> bool:
 
 
 
127
  return key.startswith(f"{self.prefix}{self.resource_separator}")
128
 
129
- async def match_prompt(self, key: str) -> bool:
 
 
 
130
  return key.startswith(f"{self.prefix}{self.prompt_separator}")
131
 
 
 
 
132
 
133
  class TimedCache:
134
  def __init__(self, expiration: datetime.timedelta):
@@ -146,6 +144,9 @@ class TimedCache:
146
  else:
147
  return NOT_FOUND
148
 
 
 
 
149
 
150
  @asynccontextmanager
151
  async def default_lifespan(server: "FastMCP") -> AsyncIterator[Any]:
@@ -194,7 +195,7 @@ class FastMCP(Generic[LifespanResultT]):
194
  )
195
  )
196
 
197
- self._server_mounts: dict[str, MountedServer] = {}
198
 
199
  if lifespan is None:
200
  lifespan = default_lifespan
@@ -287,40 +288,48 @@ class FastMCP(Generic[LifespanResultT]):
287
 
288
  async def get_tools(self) -> dict[str, Tool]:
289
  """Get all registered tools, indexed by registered key."""
290
- tools = {}
291
- for server in self._server_mounts.values():
292
- server_tools = await server.get_tools()
293
- tools.update(server_tools)
294
- tools.update(self._tool_manager.get_tools())
 
 
295
  return tools
296
 
297
  async def get_resources(self) -> dict[str, Resource]:
298
  """Get all registered resources, indexed by registered key."""
299
- resources = {}
300
- for server in self._server_mounts.values():
301
- server_resources = await server.get_resources()
302
- resources.update(server_resources)
303
- resources.update(self._resource_manager.get_resources())
 
 
304
  return resources
305
 
306
  async def get_resource_templates(self) -> dict[str, ResourceTemplate]:
307
  """Get all registered resource templates, indexed by registered key."""
308
- templates = {}
309
- for server in self._server_mounts.values():
310
- server_templates = await server.get_resource_templates()
311
- templates.update(server_templates)
312
- templates.update(self._resource_manager.get_templates())
 
 
313
  return templates
314
 
315
  async def get_prompts(self) -> dict[str, Prompt]:
316
  """
317
  List all available prompts.
318
  """
319
- prompts = {}
320
- for server in self._server_mounts.values():
321
- server_prompts = await server.get_prompts()
322
- prompts.update(server_prompts)
323
- prompts.update(self._prompt_manager.get_prompts())
 
 
324
  return prompts
325
 
326
  async def _mcp_list_tools(self) -> list[MCPTool]:
@@ -368,14 +377,15 @@ class FastMCP(Generic[LifespanResultT]):
368
  self, key: str, arguments: dict[str, Any]
369
  ) -> list[TextContent | ImageContent | EmbeddedResource]:
370
  """Call a tool by name with arguments."""
371
- if self._tool_manager.get_tool(key):
372
  context = self.get_context()
373
  result = await self._tool_manager.call_tool(key, arguments, context=context)
374
 
375
  else:
376
- for server in self._server_mounts.values():
377
  if server.match_tool(key):
378
- result = await server.server._mcp_call_tool(key, arguments)
 
379
  break
380
  else:
381
  raise NotFoundError(f"Unknown tool: {key}")
@@ -385,10 +395,9 @@ class FastMCP(Generic[LifespanResultT]):
385
  """
386
  Read a resource by URI, in the format expected by the low-level MCP
387
  server.
388
-
389
- See `read_resource` for a more ergonomic way to read resources.
390
  """
391
- if resource := await self._resource_manager.get_resource(uri):
 
392
  try:
393
  content = await resource.read()
394
  return [
@@ -398,9 +407,10 @@ class FastMCP(Generic[LifespanResultT]):
398
  logger.error(f"Error reading resource {uri}: {e}")
399
  raise ResourceError(str(e))
400
  else:
401
- for server in self._server_mounts.values():
402
  if server.match_resource(str(uri)):
403
- return await server.server._mcp_read_resource(uri)
 
404
  else:
405
  raise NotFoundError(f"Unknown resource: {uri}")
406
 
@@ -411,15 +421,15 @@ class FastMCP(Generic[LifespanResultT]):
411
  Get a prompt by name with arguments, in the format expected by the low-level
412
  MCP server.
413
 
414
- See `get_prompt` for a more ergonomic way to get prompts.
415
  """
416
- if prompt := self._prompt_manager.get_prompt(name):
417
- messages = await prompt.render(arguments)
418
  return GetPromptResult(messages=pydantic_core.to_jsonable_python(messages))
419
  else:
420
- for server in self._server_mounts.values():
421
  if server.match_prompt(name):
422
- return await server.server._mcp_get_prompt(name, arguments)
 
423
  else:
424
  raise NotFoundError(f"Unknown prompt: {name}")
425
 
@@ -444,6 +454,7 @@ class FastMCP(Generic[LifespanResultT]):
444
  self._tool_manager.add_tool_from_fn(
445
  fn, name=name, description=description, tags=tags
446
  )
 
447
 
448
  def tool(
449
  self,
@@ -499,6 +510,7 @@ class FastMCP(Generic[LifespanResultT]):
499
  """
500
 
501
  self._resource_manager.add_resource(resource, key=key)
 
502
 
503
  def add_resource_fn(
504
  self,
@@ -530,6 +542,7 @@ class FastMCP(Generic[LifespanResultT]):
530
  mime_type=mime_type,
531
  tags=tags,
532
  )
 
533
 
534
  def resource(
535
  self,
@@ -585,7 +598,7 @@ class FastMCP(Generic[LifespanResultT]):
585
  )
586
 
587
  def decorator(fn: AnyFunction) -> AnyFunction:
588
- self._resource_manager.add_resource_or_template_from_fn(
589
  fn=fn,
590
  uri=uri,
591
  name=name,
@@ -615,6 +628,7 @@ class FastMCP(Generic[LifespanResultT]):
615
  description=description,
616
  tags=tags,
617
  )
 
618
 
619
  def prompt(
620
  self,
@@ -738,10 +752,12 @@ class FastMCP(Generic[LifespanResultT]):
738
  resource_separator=resource_separator,
739
  prompt_separator=prompt_separator,
740
  )
741
- self._server_mounts[prefix] = mounted_server
 
742
 
743
  def unmount(self, prefix: str) -> None:
744
- self._server_mounts.pop(prefix)
 
745
 
746
  async def import_server(
747
  self,
@@ -815,6 +831,8 @@ class FastMCP(Generic[LifespanResultT]):
815
  logger.debug(f"Imported templates with prefix '{resource_prefix}'")
816
  logger.debug(f"Imported prompts with prefix '{prompt_prefix}'")
817
 
 
 
818
  @classmethod
819
  def from_openapi(
820
  cls, openapi_spec: dict[str, Any], client: httpx.AsyncClient, **settings: Any
 
67
  tool_separator: str | None = None,
68
  resource_separator: str | None = None,
69
  prompt_separator: str | None = None,
 
70
  ):
71
  if tool_separator is None:
72
  tool_separator = "_"
 
80
  self.tool_separator = tool_separator
81
  self.resource_separator = resource_separator
82
  self.prompt_separator = prompt_separator
 
 
 
83
 
84
  async def get_tools(self) -> dict[str, Tool]:
85
+ tools = await self.server.get_tools()
 
 
 
 
86
  return {
87
  f"{self.prefix}{self.tool_separator}{key}": tool
88
+ for key, tool in tools.items()
89
  }
90
 
91
  async def get_resources(self) -> dict[str, Resource]:
92
+ resources = await self.server.get_resources()
93
+ return {
94
+ f"{self.prefix}{self.resource_separator}{key}": resource
95
+ for key, resource in resources.items()
96
+ }
 
97
 
98
  async def get_resource_templates(self) -> dict[str, ResourceTemplate]:
99
+ templates = await self.server.get_resource_templates()
100
+ return {
101
+ f"{self.prefix}{self.resource_separator}{key}": template
102
+ for key, template in templates.items()
103
+ }
 
104
 
105
  async def get_prompts(self) -> dict[str, Prompt]:
106
+ prompts = await self.server.get_prompts()
107
+ return {
108
+ f"{self.prefix}{self.prompt_separator}{key}": prompt
109
+ for key, prompt in prompts.items()
110
+ }
111
+
112
+ def match_tool(self, key: str) -> bool:
 
113
  return key.startswith(f"{self.prefix}{self.tool_separator}")
114
 
115
+ def strip_tool_prefix(self, key: str) -> str:
116
+ return key.removeprefix(f"{self.prefix}{self.tool_separator}")
117
+
118
+ def match_resource(self, key: str) -> bool:
119
  return key.startswith(f"{self.prefix}{self.resource_separator}")
120
 
121
+ def strip_resource_prefix(self, key: str) -> str:
122
+ return key.removeprefix(f"{self.prefix}{self.resource_separator}")
123
+
124
+ def match_prompt(self, key: str) -> bool:
125
  return key.startswith(f"{self.prefix}{self.prompt_separator}")
126
 
127
+ def strip_prompt_prefix(self, key: str) -> str:
128
+ return key.removeprefix(f"{self.prefix}{self.prompt_separator}")
129
+
130
 
131
  class TimedCache:
132
  def __init__(self, expiration: datetime.timedelta):
 
144
  else:
145
  return NOT_FOUND
146
 
147
+ def clear(self) -> None:
148
+ self.cache.clear()
149
+
150
 
151
  @asynccontextmanager
152
  async def default_lifespan(server: "FastMCP") -> AsyncIterator[Any]:
 
195
  )
196
  )
197
 
198
+ self._mounted_servers: dict[str, MountedServer] = {}
199
 
200
  if lifespan is None:
201
  lifespan = default_lifespan
 
288
 
289
  async def get_tools(self) -> dict[str, Tool]:
290
  """Get all registered tools, indexed by registered key."""
291
+ if (tools := self._cache.get("tools")) is NOT_FOUND:
292
+ tools = {}
293
+ for server in self._mounted_servers.values():
294
+ server_tools = await server.get_tools()
295
+ tools.update(server_tools)
296
+ tools.update(self._tool_manager.get_tools())
297
+ self._cache.set("tools", tools)
298
  return tools
299
 
300
  async def get_resources(self) -> dict[str, Resource]:
301
  """Get all registered resources, indexed by registered key."""
302
+ if (resources := self._cache.get("resources")) is NOT_FOUND:
303
+ resources = {}
304
+ for server in self._mounted_servers.values():
305
+ server_resources = await server.get_resources()
306
+ resources.update(server_resources)
307
+ resources.update(self._resource_manager.get_resources())
308
+ self._cache.set("resources", resources)
309
  return resources
310
 
311
  async def get_resource_templates(self) -> dict[str, ResourceTemplate]:
312
  """Get all registered resource templates, indexed by registered key."""
313
+ if (templates := self._cache.get("resource_templates")) is NOT_FOUND:
314
+ templates = {}
315
+ for server in self._mounted_servers.values():
316
+ server_templates = await server.get_resource_templates()
317
+ templates.update(server_templates)
318
+ templates.update(self._resource_manager.get_templates())
319
+ self._cache.set("resource_templates", templates)
320
  return templates
321
 
322
  async def get_prompts(self) -> dict[str, Prompt]:
323
  """
324
  List all available prompts.
325
  """
326
+ if (prompts := self._cache.get("prompts")) is NOT_FOUND:
327
+ prompts = {}
328
+ for server in self._mounted_servers.values():
329
+ server_prompts = await server.get_prompts()
330
+ prompts.update(server_prompts)
331
+ prompts.update(self._prompt_manager.get_prompts())
332
+ self._cache.set("prompts", prompts)
333
  return prompts
334
 
335
  async def _mcp_list_tools(self) -> list[MCPTool]:
 
377
  self, key: str, arguments: dict[str, Any]
378
  ) -> list[TextContent | ImageContent | EmbeddedResource]:
379
  """Call a tool by name with arguments."""
380
+ if self._tool_manager.has_tool(key):
381
  context = self.get_context()
382
  result = await self._tool_manager.call_tool(key, arguments, context=context)
383
 
384
  else:
385
+ for server in self._mounted_servers.values():
386
  if server.match_tool(key):
387
+ new_key = server.strip_tool_prefix(key)
388
+ result = await server.server._mcp_call_tool(new_key, arguments)
389
  break
390
  else:
391
  raise NotFoundError(f"Unknown tool: {key}")
 
395
  """
396
  Read a resource by URI, in the format expected by the low-level MCP
397
  server.
 
 
398
  """
399
+ if self._resource_manager.has_resource(uri):
400
+ resource = await self._resource_manager.get_resource(uri)
401
  try:
402
  content = await resource.read()
403
  return [
 
407
  logger.error(f"Error reading resource {uri}: {e}")
408
  raise ResourceError(str(e))
409
  else:
410
+ for server in self._mounted_servers.values():
411
  if server.match_resource(str(uri)):
412
+ new_uri = server.strip_resource_prefix(str(uri))
413
+ return await server.server._mcp_read_resource(new_uri)
414
  else:
415
  raise NotFoundError(f"Unknown resource: {uri}")
416
 
 
421
  Get a prompt by name with arguments, in the format expected by the low-level
422
  MCP server.
423
 
 
424
  """
425
+ if self._prompt_manager.has_prompt(name):
426
+ messages = await self._prompt_manager.render_prompt(name, arguments)
427
  return GetPromptResult(messages=pydantic_core.to_jsonable_python(messages))
428
  else:
429
+ for server in self._mounted_servers.values():
430
  if server.match_prompt(name):
431
+ new_key = server.strip_prompt_prefix(name)
432
+ return await server.server._mcp_get_prompt(new_key, arguments)
433
  else:
434
  raise NotFoundError(f"Unknown prompt: {name}")
435
 
 
454
  self._tool_manager.add_tool_from_fn(
455
  fn, name=name, description=description, tags=tags
456
  )
457
+ self._cache.clear()
458
 
459
  def tool(
460
  self,
 
510
  """
511
 
512
  self._resource_manager.add_resource(resource, key=key)
513
+ self._cache.clear()
514
 
515
  def add_resource_fn(
516
  self,
 
542
  mime_type=mime_type,
543
  tags=tags,
544
  )
545
+ self._cache.clear()
546
 
547
  def resource(
548
  self,
 
598
  )
599
 
600
  def decorator(fn: AnyFunction) -> AnyFunction:
601
+ self.add_resource_fn(
602
  fn=fn,
603
  uri=uri,
604
  name=name,
 
628
  description=description,
629
  tags=tags,
630
  )
631
+ self._cache.clear()
632
 
633
  def prompt(
634
  self,
 
752
  resource_separator=resource_separator,
753
  prompt_separator=prompt_separator,
754
  )
755
+ self._mounted_servers[prefix] = mounted_server
756
+ self._cache.clear()
757
 
758
  def unmount(self, prefix: str) -> None:
759
+ self._mounted_servers.pop(prefix)
760
+ self._cache.clear()
761
 
762
  async def import_server(
763
  self,
 
831
  logger.debug(f"Imported templates with prefix '{resource_prefix}'")
832
  logger.debug(f"Imported prompts with prefix '{prompt_prefix}'")
833
 
834
+ self._cache.clear()
835
+
836
  @classmethod
837
  def from_openapi(
838
  cls, openapi_spec: dict[str, Any], client: httpx.AsyncClient, **settings: Any
src/fastmcp/settings.py CHANGED
@@ -63,7 +63,7 @@ class ServerSettings(BaseSettings):
63
  )
64
 
65
  # cache settings (for checking mounted servers)
66
- cache_expiration_seconds: float = 5
67
 
68
 
69
  class ClientSettings(BaseSettings):
 
63
  )
64
 
65
  # cache settings (for checking mounted servers)
66
+ cache_expiration_seconds: float = 0
67
 
68
 
69
  class ClientSettings(BaseSettings):
src/fastmcp/tools/tool_manager.py CHANGED
@@ -37,9 +37,15 @@ class ToolManager:
37
 
38
  self.duplicate_behavior = duplicate_behavior
39
 
40
- def get_tool(self, key: str) -> Tool | None:
 
 
 
 
41
  """Get tool by key."""
42
- return self._tools.get(key)
 
 
43
 
44
  def get_tools(self) -> dict[str, Tool]:
45
  """Get all registered tools, indexed by registered key."""
 
37
 
38
  self.duplicate_behavior = duplicate_behavior
39
 
40
+ def has_tool(self, key: str) -> bool:
41
+ """Check if a tool exists."""
42
+ return key in self._tools
43
+
44
+ def get_tool(self, key: str) -> Tool:
45
  """Get tool by key."""
46
+ if key in self._tools:
47
+ return self._tools[key]
48
+ raise NotFoundError(f"Unknown tool: {key}")
49
 
50
  def get_tools(self) -> dict[str, Tool]:
51
  """Get all registered tools, indexed by registered key."""
tests/server/test_import_server.py CHANGED
@@ -221,12 +221,10 @@ async def test_call_imported_custom_named_tool():
221
  api_app.add_tool(fetch_data, name="get_data")
222
  await main_app.import_server("api", api_app)
223
 
224
- context = main_app.get_context()
225
- result = await main_app._tool_manager.call_tool(
226
- "api_get_data", {"query": "test"}, context=context
227
- )
228
- assert isinstance(result[0], TextContent)
229
- assert result[0].text == "Data for query: test"
230
 
231
 
232
  async def test_first_level_importing_with_custom_name():
@@ -382,6 +380,7 @@ async def test_import_with_proxy_resource_templates():
382
  await main_app.import_server("api", proxy_app)
383
 
384
  # Instantiate the template through the main app with the prefixed key
 
385
  quoted_name = quote("John Doe", safe="")
386
  quoted_email = quote("john@example.com", safe="")
387
  async with Client(main_app) as client:
 
221
  api_app.add_tool(fetch_data, name="get_data")
222
  await main_app.import_server("api", api_app)
223
 
224
+ async with Client(main_app) as client:
225
+ result = await client.call_tool("api_get_data", {"query": "test"})
226
+ assert isinstance(result[0], TextContent)
227
+ assert result[0].text == "Data for query: test"
 
 
228
 
229
 
230
  async def test_first_level_importing_with_custom_name():
 
380
  await main_app.import_server("api", proxy_app)
381
 
382
  # Instantiate the template through the main app with the prefixed key
383
+
384
  quoted_name = quote("John Doe", safe="")
385
  quoted_email = quote("john@example.com", safe="")
386
  async with Client(main_app) as client:
tests/server/test_mount.py ADDED
@@ -0,0 +1,429 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+
3
+ import pytest
4
+ from mcp.server.lowlevel.helper_types import ReadResourceContents
5
+ from mcp.types import TextContent, TextResourceContents
6
+
7
+ from fastmcp import FastMCP
8
+ from fastmcp.client import Client
9
+ from fastmcp.client.transports import FastMCPTransport
10
+ from fastmcp.exceptions import NotFoundError
11
+
12
+
13
+ class TestBasicMount:
14
+ """Test basic mounting functionality."""
15
+
16
+ async def test_mount_simple_server(self):
17
+ """Test mounting a simple server and accessing its tool."""
18
+ # Create main app and sub-app
19
+ main_app = FastMCP("MainApp")
20
+ sub_app = FastMCP("SubApp")
21
+
22
+ # Add a tool to the sub-app
23
+ @sub_app.tool()
24
+ def sub_tool() -> str:
25
+ return "This is from the sub app"
26
+
27
+ # Mount the sub-app to the main app
28
+ main_app.mount("sub", sub_app)
29
+
30
+ # Get tools from main app, should include sub_app's tools
31
+ tools = await main_app.get_tools()
32
+ assert "sub_sub_tool" in tools
33
+
34
+ async with Client(main_app) as client:
35
+ result = await client.call_tool("sub_sub_tool", {})
36
+ assert isinstance(result[0], TextContent)
37
+ assert result[0].text == "This is from the sub app"
38
+
39
+ async def test_mount_with_custom_separator(self):
40
+ """Test mounting with a custom tool separator."""
41
+ main_app = FastMCP("MainApp")
42
+ sub_app = FastMCP("SubApp")
43
+
44
+ @sub_app.tool()
45
+ def greet(name: str) -> str:
46
+ return f"Hello, {name}!"
47
+
48
+ # Mount with custom separator
49
+ main_app.mount("sub", sub_app, tool_separator="-")
50
+
51
+ # Tool should be accessible with custom separator
52
+ tools = await main_app.get_tools()
53
+ assert "sub-greet" in tools
54
+
55
+ # Call the tool
56
+ result = await main_app._mcp_call_tool("sub-greet", {"name": "World"})
57
+ assert isinstance(result[0], TextContent)
58
+ assert result[0].text == "Hello, World!"
59
+
60
+ async def test_unmount_server(self):
61
+ """Test unmounting a server removes access to its tools."""
62
+ main_app = FastMCP("MainApp")
63
+ sub_app = FastMCP("SubApp")
64
+
65
+ @sub_app.tool()
66
+ def sub_tool() -> str:
67
+ return "This is from the sub app"
68
+
69
+ # Mount the sub-app
70
+ main_app.mount("sub", sub_app)
71
+
72
+ # Verify it was mounted
73
+ tools = await main_app.get_tools()
74
+ assert "sub_sub_tool" in tools
75
+
76
+ # Unmount the sub-app
77
+ main_app.unmount("sub")
78
+
79
+ # Verify it was unmounted
80
+ tools = await main_app.get_tools()
81
+ assert "sub_sub_tool" not in tools
82
+
83
+ # Calling the tool should fail
84
+ with pytest.raises(NotFoundError, match="Unknown tool: sub_sub_tool"):
85
+ await main_app._mcp_call_tool("sub_sub_tool", {})
86
+
87
+
88
+ class TestMultipleServerMount:
89
+ """Test mounting multiple servers simultaneously."""
90
+
91
+ async def test_mount_multiple_servers(self):
92
+ """Test mounting multiple servers with different prefixes."""
93
+ main_app = FastMCP("MainApp")
94
+ weather_app = FastMCP("WeatherApp")
95
+ news_app = FastMCP("NewsApp")
96
+
97
+ @weather_app.tool()
98
+ def get_forecast() -> str:
99
+ return "Weather forecast"
100
+
101
+ @news_app.tool()
102
+ def get_headlines() -> str:
103
+ return "News headlines"
104
+
105
+ # Mount both apps
106
+ main_app.mount("weather", weather_app)
107
+ main_app.mount("news", news_app)
108
+
109
+ # Check both are accessible
110
+ tools = await main_app.get_tools()
111
+ assert "weather_get_forecast" in tools
112
+ assert "news_get_headlines" in tools
113
+
114
+ # Call tools from both mounted servers
115
+ result1 = await main_app._mcp_call_tool("weather_get_forecast", {})
116
+ assert isinstance(result1[0], TextContent)
117
+ assert result1[0].text == "Weather forecast"
118
+
119
+ result2 = await main_app._mcp_call_tool("news_get_headlines", {})
120
+ assert isinstance(result2[0], TextContent)
121
+ assert result2[0].text == "News headlines"
122
+
123
+ async def test_mount_same_prefix(self):
124
+ """Test that mounting with the same prefix replaces the previous mount."""
125
+ main_app = FastMCP("MainApp")
126
+ first_app = FastMCP("FirstApp")
127
+ second_app = FastMCP("SecondApp")
128
+
129
+ @first_app.tool()
130
+ def first_tool() -> str:
131
+ return "First app tool"
132
+
133
+ @second_app.tool()
134
+ def second_tool() -> str:
135
+ return "Second app tool"
136
+
137
+ # Mount first app
138
+ main_app.mount("api", first_app)
139
+ tools = await main_app.get_tools()
140
+ assert "api_first_tool" in tools
141
+
142
+ # Mount second app with same prefix
143
+ main_app.mount("api", second_app)
144
+ tools = await main_app.get_tools()
145
+
146
+ # First app's tool should no longer be accessible
147
+ assert "api_first_tool" not in tools
148
+
149
+ # Second app's tool should be accessible
150
+ assert "api_second_tool" in tools
151
+
152
+
153
+ class TestDynamicChanges:
154
+ """Test that changes to mounted servers are reflected dynamically."""
155
+
156
+ async def test_adding_tool_after_mounting(self):
157
+ """Test that tools added after mounting are accessible."""
158
+ main_app = FastMCP("MainApp")
159
+ sub_app = FastMCP("SubApp")
160
+
161
+ # Mount the sub-app before adding any tools
162
+ main_app.mount("sub", sub_app)
163
+
164
+ # Initially, there should be no tools from sub_app
165
+ tools = await main_app.get_tools()
166
+ assert not any(key.startswith("sub_") for key in tools)
167
+
168
+ # Add a tool to the sub-app after mounting
169
+ @sub_app.tool()
170
+ def dynamic_tool() -> str:
171
+ return "Added after mounting"
172
+
173
+ # The tool should be accessible through the main app
174
+ tools = await main_app.get_tools()
175
+ assert "sub_dynamic_tool" in tools
176
+
177
+ # Call the dynamically added tool
178
+ result = await main_app._mcp_call_tool("sub_dynamic_tool", {})
179
+ assert isinstance(result[0], TextContent)
180
+ assert result[0].text == "Added after mounting"
181
+
182
+ async def test_removing_tool_after_mounting(self):
183
+ """Test that tools removed from mounted servers are no longer accessible."""
184
+ main_app = FastMCP("MainApp")
185
+ sub_app = FastMCP("SubApp")
186
+
187
+ @sub_app.tool()
188
+ def temp_tool() -> str:
189
+ return "Temporary tool"
190
+
191
+ # Mount the sub-app
192
+ main_app.mount("sub", sub_app)
193
+
194
+ # Initially, the tool should be accessible
195
+ tools = await main_app.get_tools()
196
+ assert "sub_temp_tool" in tools
197
+
198
+ # Remove the tool from sub_app
199
+ sub_app._tool_manager._tools.pop("temp_tool")
200
+
201
+ # The tool should no longer be accessible
202
+ # Refresh the cache by clearing it
203
+ main_app._cache.cache.clear()
204
+ tools = await main_app.get_tools()
205
+ assert "sub_temp_tool" not in tools
206
+
207
+
208
+ class TestResourcesAndTemplates:
209
+ """Test mounting with resources and resource templates."""
210
+
211
+ async def test_mount_with_resources(self):
212
+ """Test mounting a server with resources."""
213
+ main_app = FastMCP("MainApp")
214
+ data_app = FastMCP("DataApp")
215
+
216
+ @data_app.resource(uri="data://users")
217
+ async def get_users():
218
+ return ["user1", "user2"]
219
+
220
+ # Mount the data app
221
+ main_app.mount("data", data_app)
222
+
223
+ # Resource should be accessible through main app
224
+ resources = await main_app.get_resources()
225
+ assert any("data+data://users" in str(uri) for uri in resources)
226
+
227
+ async with Client(main_app) as client:
228
+ resource = await client.read_resource("data+data://users")
229
+ assert isinstance(resource[0], TextResourceContents)
230
+ assert resource[0].text == '["user1", "user2"]'
231
+
232
+ async def test_mount_with_resource_templates(self):
233
+ """Test mounting a server with resource templates."""
234
+ main_app = FastMCP("MainApp")
235
+ user_app = FastMCP("UserApp")
236
+
237
+ @user_app.resource(uri="users://{user_id}/profile")
238
+ def get_user_profile(user_id: str) -> dict:
239
+ return {"id": user_id, "name": f"User {user_id}"}
240
+
241
+ # Mount the user app
242
+ main_app.mount("api", user_app)
243
+
244
+ # Template should be accessible through main app
245
+ templates = await main_app.get_resource_templates()
246
+ assert any("api+users://{user_id}/profile" in str(t) for t in templates)
247
+
248
+ # Read from the template
249
+ result = await main_app._mcp_read_resource("api+users://123/profile")
250
+ assert isinstance(result[0], ReadResourceContents)
251
+ profile = json.loads(result[0].content)
252
+ assert profile["id"] == "123"
253
+ assert profile["name"] == "User 123"
254
+
255
+ async def test_adding_resource_after_mounting(self):
256
+ """Test adding a resource after mounting."""
257
+ main_app = FastMCP("MainApp")
258
+ data_app = FastMCP("DataApp")
259
+
260
+ # Mount the data app before adding resources
261
+ main_app.mount("data", data_app)
262
+
263
+ # Add a resource after mounting
264
+ @data_app.resource(uri="data://config")
265
+ def get_config():
266
+ return {"version": "1.0"}
267
+
268
+ # Resource should be accessible through main app
269
+ resources = await main_app.get_resources()
270
+ assert any("data+data://config" in str(uri) for uri in resources)
271
+
272
+ # Read the resource
273
+ result = await main_app._mcp_read_resource("data+data://config")
274
+ assert isinstance(result[0], ReadResourceContents)
275
+ config = json.loads(result[0].content)
276
+ assert config["version"] == "1.0"
277
+
278
+
279
+ class TestPrompts:
280
+ """Test mounting with prompts."""
281
+
282
+ async def test_mount_with_prompts(self):
283
+ """Test mounting a server with prompts."""
284
+ main_app = FastMCP("MainApp")
285
+ assistant_app = FastMCP("AssistantApp")
286
+
287
+ @assistant_app.prompt()
288
+ def greeting(name: str) -> str:
289
+ return f"Hello, {name}!"
290
+
291
+ # Mount the assistant app
292
+ main_app.mount("assistant", assistant_app)
293
+
294
+ # Prompt should be accessible through main app
295
+ prompts = await main_app.get_prompts()
296
+ assert "assistant_greeting" in prompts
297
+
298
+ # Render the prompt
299
+ result = await main_app._mcp_get_prompt("assistant_greeting", {"name": "World"})
300
+ assert result.messages is not None
301
+ # The message should contain our greeting text
302
+
303
+ async def test_adding_prompt_after_mounting(self):
304
+ """Test adding a prompt after mounting."""
305
+ main_app = FastMCP("MainApp")
306
+ assistant_app = FastMCP("AssistantApp")
307
+
308
+ # Mount the assistant app before adding prompts
309
+ main_app.mount("assistant", assistant_app)
310
+
311
+ # Add a prompt after mounting
312
+ @assistant_app.prompt()
313
+ def farewell(name: str) -> str:
314
+ return f"Goodbye, {name}!"
315
+
316
+ # Prompt should be accessible through main app
317
+ prompts = await main_app.get_prompts()
318
+ assert "assistant_farewell" in prompts
319
+
320
+ # Render the prompt
321
+ result = await main_app._mcp_get_prompt("assistant_farewell", {"name": "World"})
322
+ assert result.messages is not None
323
+ # The message should contain our farewell text
324
+
325
+
326
+ class TestProxyServer:
327
+ """Test mounting a proxy server."""
328
+
329
+ async def test_mount_proxy_server(self):
330
+ """Test mounting a proxy server."""
331
+ # Create original server
332
+ original_server = FastMCP("OriginalServer")
333
+
334
+ @original_server.tool()
335
+ def get_data(query: str) -> str:
336
+ return f"Data for {query}"
337
+
338
+ # Create proxy server
339
+ proxy_server = FastMCP.from_client(
340
+ Client(transport=FastMCPTransport(original_server))
341
+ )
342
+
343
+ # Mount proxy server
344
+ main_app = FastMCP("MainApp")
345
+ main_app.mount("proxy", proxy_server)
346
+
347
+ # Tool should be accessible through main app
348
+ tools = await main_app.get_tools()
349
+ assert "proxy_get_data" in tools
350
+
351
+ # Call the tool
352
+ result = await main_app._mcp_call_tool("proxy_get_data", {"query": "test"})
353
+ assert isinstance(result[0], TextContent)
354
+ assert result[0].text == "Data for test"
355
+
356
+ async def test_dynamically_adding_to_proxied_server(self):
357
+ """Test that changes to the original server are reflected in the mounted proxy."""
358
+ # Create original server
359
+ original_server = FastMCP("OriginalServer")
360
+
361
+ # Create proxy server
362
+ proxy_server = FastMCP.from_client(
363
+ Client(transport=FastMCPTransport(original_server))
364
+ )
365
+
366
+ # Mount proxy server
367
+ main_app = FastMCP("MainApp")
368
+ main_app.mount("proxy", proxy_server)
369
+
370
+ # Add a tool to the original server
371
+ @original_server.tool()
372
+ def dynamic_data() -> str:
373
+ return "Dynamic data"
374
+
375
+ # Tool should be accessible through main app via proxy
376
+ tools = await main_app.get_tools()
377
+ assert "proxy_dynamic_data" in tools
378
+
379
+ # Call the tool
380
+ result = await main_app._mcp_call_tool("proxy_dynamic_data", {})
381
+ assert isinstance(result[0], TextContent)
382
+ assert result[0].text == "Dynamic data"
383
+
384
+ async def test_proxy_server_with_resources(self):
385
+ """Test mounting a proxy server with resources."""
386
+ # Create original server
387
+ original_server = FastMCP("OriginalServer")
388
+
389
+ @original_server.resource(uri="config://settings")
390
+ def get_config():
391
+ return {"api_key": "12345"}
392
+
393
+ # Create proxy server
394
+ proxy_server = FastMCP.from_client(
395
+ Client(transport=FastMCPTransport(original_server))
396
+ )
397
+
398
+ # Mount proxy server
399
+ main_app = FastMCP("MainApp")
400
+ main_app.mount("proxy", proxy_server)
401
+
402
+ # Resource should be accessible through main app
403
+ result = await main_app._mcp_read_resource("proxy+config://settings")
404
+ assert isinstance(result[0], ReadResourceContents)
405
+ config = json.loads(result[0].content)
406
+ assert config["api_key"] == "12345"
407
+
408
+ async def test_proxy_server_with_prompts(self):
409
+ """Test mounting a proxy server with prompts."""
410
+ # Create original server
411
+ original_server = FastMCP("OriginalServer")
412
+
413
+ @original_server.prompt()
414
+ def welcome(name: str) -> str:
415
+ return f"Welcome, {name}!"
416
+
417
+ # Create proxy server
418
+ proxy_server = FastMCP.from_client(
419
+ Client(transport=FastMCPTransport(original_server))
420
+ )
421
+
422
+ # Mount proxy server
423
+ main_app = FastMCP("MainApp")
424
+ main_app.mount("proxy", proxy_server)
425
+
426
+ # Prompt should be accessible through main app
427
+ result = await main_app._mcp_get_prompt("proxy_welcome", {"name": "World"})
428
+ assert result.messages is not None
429
+ # The message should contain our welcome text
tests/tools/test_tool_manager.py CHANGED
@@ -523,7 +523,8 @@ class TestCustomToolNames:
523
  assert tool.name == "custom_name"
524
  assert tool.fn.__name__ == "original_fn"
525
  # The tool should not be accessible via its original function name
526
- assert manager.get_tool("original_fn") is None
 
527
 
528
  def test_add_tool_object_with_custom_key(self):
529
  """Test adding a Tool object with a custom key using add_tool()."""
@@ -542,7 +543,8 @@ class TestCustomToolNames:
542
  # But the tool's .name is unchanged
543
  assert stored.name == "my_tool"
544
  # The tool is not accessible under its original name
545
- assert manager.get_tool("my_tool") is None
 
546
 
547
  async def test_call_tool_with_custom_name(self):
548
  """Test calling a tool added with a custom name."""
 
523
  assert tool.name == "custom_name"
524
  assert tool.fn.__name__ == "original_fn"
525
  # The tool should not be accessible via its original function name
526
+ with pytest.raises(NotFoundError, match="Unknown tool: original_fn"):
527
+ manager.get_tool("original_fn")
528
 
529
  def test_add_tool_object_with_custom_key(self):
530
  """Test adding a Tool object with a custom key using add_tool()."""
 
543
  # But the tool's .name is unchanged
544
  assert stored.name == "my_tool"
545
  # The tool is not accessible under its original name
546
+ with pytest.raises(NotFoundError, match="Unknown tool: my_tool"):
547
+ manager.get_tool("my_tool")
548
 
549
  async def test_call_tool_with_custom_name(self):
550
  """Test calling a tool added with a custom name."""