Jeremiah Lowin commited on
Commit
fa216f2
·
1 Parent(s): fe6c0c1

Consolidate prefix logic

Browse files
src/fastmcp/server/server.py CHANGED
@@ -12,6 +12,7 @@ from contextlib import (
12
  AsyncExitStack,
13
  asynccontextmanager,
14
  )
 
15
  from functools import partial
16
  from pathlib import Path
17
  from typing import TYPE_CHECKING, Any, Generic, Literal, overload
@@ -322,10 +323,14 @@ class FastMCP(Generic[LifespanResultT]):
322
  """Get all registered tools, indexed by registered key."""
323
  if (tools := self._cache.get("tools")) is self._cache.NOT_FOUND:
324
  tools: dict[str, Tool] = {}
325
- for prefix, server in self._mounted_servers.items():
326
  try:
327
- server_tools = await server.get_tools()
328
- tools.update(server_tools)
 
 
 
 
329
  except Exception as e:
330
  logger.warning(
331
  f"Failed to get tools from mounted server '{prefix}': {e}"
@@ -345,10 +350,17 @@ class FastMCP(Generic[LifespanResultT]):
345
  """Get all registered resources, indexed by registered key."""
346
  if (resources := self._cache.get("resources")) is self._cache.NOT_FOUND:
347
  resources: dict[str, Resource] = {}
348
- for prefix, server in self._mounted_servers.items():
349
  try:
350
- server_resources = await server.get_resources()
351
- resources.update(server_resources)
 
 
 
 
 
 
 
352
  except Exception as e:
353
  logger.warning(
354
  f"Failed to get resources from mounted server '{prefix}': {e}"
@@ -370,10 +382,19 @@ class FastMCP(Generic[LifespanResultT]):
370
  templates := self._cache.get("resource_templates")
371
  ) is self._cache.NOT_FOUND:
372
  templates: dict[str, ResourceTemplate] = {}
373
- for prefix, server in self._mounted_servers.items():
374
  try:
375
- server_templates = await server.get_resource_templates()
376
- templates.update(server_templates)
 
 
 
 
 
 
 
 
 
377
  except Exception as e:
378
  logger.warning(
379
  "Failed to get resource templates from mounted server "
@@ -396,10 +417,15 @@ class FastMCP(Generic[LifespanResultT]):
396
  """
397
  if (prompts := self._cache.get("prompts")) is self._cache.NOT_FOUND:
398
  prompts: dict[str, Prompt] = {}
399
- for prefix, server in self._mounted_servers.items():
400
  try:
401
- server_prompts = await server.get_prompts()
402
- prompts.update(server_prompts)
 
 
 
 
 
403
  except Exception as e:
404
  logger.warning(
405
  f"Failed to get prompts from mounted server '{prefix}': {e}"
@@ -562,10 +588,10 @@ class FastMCP(Generic[LifespanResultT]):
562
  return await self._tool_manager.call_tool(key, arguments)
563
 
564
  # Check mounted servers to see if they have the tool
565
- for server in self._mounted_servers.values():
566
- if server.match_tool(key):
567
- tool_key = server.strip_tool_prefix(key)
568
- return await server.server._call_tool(tool_key, arguments)
569
 
570
  raise NotFoundError(f"Unknown tool: {key!r}")
571
 
@@ -604,10 +630,14 @@ class FastMCP(Generic[LifespanResultT]):
604
  )
605
  ]
606
  else:
607
- for server in self._mounted_servers.values():
608
- if server.match_resource(str(uri)):
609
- new_uri = server.strip_resource_prefix(str(uri))
610
- return await server.server._mcp_read_resource(new_uri)
 
 
 
 
611
  else:
612
  raise NotFoundError(f"Unknown resource: {uri}")
613
 
@@ -653,10 +683,12 @@ class FastMCP(Generic[LifespanResultT]):
653
  return await self._prompt_manager.render_prompt(name, arguments)
654
 
655
  # Check mounted servers to see if they have the prompt
656
- for server in self._mounted_servers.values():
657
- if server.match_prompt(name):
658
- prompt_name = server.strip_prompt_prefix(name)
659
- return await server.server._mcp_get_prompt(prompt_name, arguments)
 
 
660
 
661
  raise NotFoundError(f"Unknown prompt: {name}")
662
 
@@ -1731,60 +1763,10 @@ class FastMCP(Generic[LifespanResultT]):
1731
  return True
1732
 
1733
 
 
1734
  class MountedServer:
1735
- def __init__(
1736
- self,
1737
- prefix: str,
1738
- server: FastMCP[LifespanResultT],
1739
- ):
1740
- self.server = server
1741
- self.prefix = prefix
1742
-
1743
- async def get_tools(self) -> dict[str, Tool]:
1744
- tools = await self.server.get_tools()
1745
- return {f"{self.prefix}_{key}": tool for key, tool in tools.items()}
1746
-
1747
- async def get_resources(self) -> dict[str, Resource]:
1748
- resources = await self.server.get_resources()
1749
- return {
1750
- add_resource_prefix(
1751
- key, self.prefix, self.server.resource_prefix_format
1752
- ): resource
1753
- for key, resource in resources.items()
1754
- }
1755
-
1756
- async def get_resource_templates(self) -> dict[str, ResourceTemplate]:
1757
- templates = await self.server.get_resource_templates()
1758
- return {
1759
- add_resource_prefix(
1760
- key, self.prefix, self.server.resource_prefix_format
1761
- ): template
1762
- for key, template in templates.items()
1763
- }
1764
-
1765
- async def get_prompts(self) -> dict[str, Prompt]:
1766
- prompts = await self.server.get_prompts()
1767
- return {f"{self.prefix}_{key}": prompt for key, prompt in prompts.items()}
1768
-
1769
- def match_tool(self, key: str) -> bool:
1770
- return key.startswith(f"{self.prefix}_")
1771
-
1772
- def strip_tool_prefix(self, key: str) -> str:
1773
- return key.removeprefix(f"{self.prefix}_")
1774
-
1775
- def match_resource(self, key: str) -> bool:
1776
- return has_resource_prefix(key, self.prefix, self.server.resource_prefix_format)
1777
-
1778
- def strip_resource_prefix(self, key: str) -> str:
1779
- return remove_resource_prefix(
1780
- key, self.prefix, self.server.resource_prefix_format
1781
- )
1782
-
1783
- def match_prompt(self, key: str) -> bool:
1784
- return key.startswith(f"{self.prefix}_")
1785
-
1786
- def strip_prompt_prefix(self, key: str) -> str:
1787
- return key.removeprefix(f"{self.prefix}_")
1788
 
1789
 
1790
  def add_resource_prefix(
 
12
  AsyncExitStack,
13
  asynccontextmanager,
14
  )
15
+ from dataclasses import dataclass
16
  from functools import partial
17
  from pathlib import Path
18
  from typing import TYPE_CHECKING, Any, Generic, Literal, overload
 
323
  """Get all registered tools, indexed by registered key."""
324
  if (tools := self._cache.get("tools")) is self._cache.NOT_FOUND:
325
  tools: dict[str, Tool] = {}
326
+ for prefix, mounted_server in self._mounted_servers.items():
327
  try:
328
+ server_tools = await mounted_server.server.get_tools()
329
+ # Apply prefix to each tool key
330
+ prefixed_tools = {
331
+ f"{prefix}_{key}": tool for key, tool in server_tools.items()
332
+ }
333
+ tools.update(prefixed_tools)
334
  except Exception as e:
335
  logger.warning(
336
  f"Failed to get tools from mounted server '{prefix}': {e}"
 
350
  """Get all registered resources, indexed by registered key."""
351
  if (resources := self._cache.get("resources")) is self._cache.NOT_FOUND:
352
  resources: dict[str, Resource] = {}
353
+ for prefix, mounted_server in self._mounted_servers.items():
354
  try:
355
+ server_resources = await mounted_server.server.get_resources()
356
+ # Apply prefix to each resource key
357
+ prefixed_resources = {
358
+ add_resource_prefix(
359
+ key, prefix, mounted_server.server.resource_prefix_format
360
+ ): resource
361
+ for key, resource in server_resources.items()
362
+ }
363
+ resources.update(prefixed_resources)
364
  except Exception as e:
365
  logger.warning(
366
  f"Failed to get resources from mounted server '{prefix}': {e}"
 
382
  templates := self._cache.get("resource_templates")
383
  ) is self._cache.NOT_FOUND:
384
  templates: dict[str, ResourceTemplate] = {}
385
+ for prefix, mounted_server in self._mounted_servers.items():
386
  try:
387
+ server_templates = (
388
+ await mounted_server.server.get_resource_templates()
389
+ )
390
+ # Apply prefix to each template key
391
+ prefixed_templates = {
392
+ add_resource_prefix(
393
+ key, prefix, mounted_server.server.resource_prefix_format
394
+ ): template
395
+ for key, template in server_templates.items()
396
+ }
397
+ templates.update(prefixed_templates)
398
  except Exception as e:
399
  logger.warning(
400
  "Failed to get resource templates from mounted server "
 
417
  """
418
  if (prompts := self._cache.get("prompts")) is self._cache.NOT_FOUND:
419
  prompts: dict[str, Prompt] = {}
420
+ for prefix, mounted_server in self._mounted_servers.items():
421
  try:
422
+ server_prompts = await mounted_server.server.get_prompts()
423
+ # Apply prefix to each prompt key
424
+ prefixed_prompts = {
425
+ f"{prefix}_{key}": prompt
426
+ for key, prompt in server_prompts.items()
427
+ }
428
+ prompts.update(prefixed_prompts)
429
  except Exception as e:
430
  logger.warning(
431
  f"Failed to get prompts from mounted server '{prefix}': {e}"
 
588
  return await self._tool_manager.call_tool(key, arguments)
589
 
590
  # Check mounted servers to see if they have the tool
591
+ for prefix, mounted_server in self._mounted_servers.items():
592
+ if key.startswith(f"{prefix}_"):
593
+ tool_key = key.removeprefix(f"{prefix}_")
594
+ return await mounted_server.server._call_tool(tool_key, arguments)
595
 
596
  raise NotFoundError(f"Unknown tool: {key!r}")
597
 
 
630
  )
631
  ]
632
  else:
633
+ for prefix, mounted_server in self._mounted_servers.items():
634
+ if has_resource_prefix(
635
+ str(uri), prefix, mounted_server.server.resource_prefix_format
636
+ ):
637
+ new_uri = remove_resource_prefix(
638
+ str(uri), prefix, mounted_server.server.resource_prefix_format
639
+ )
640
+ return await mounted_server.server._mcp_read_resource(new_uri)
641
  else:
642
  raise NotFoundError(f"Unknown resource: {uri}")
643
 
 
683
  return await self._prompt_manager.render_prompt(name, arguments)
684
 
685
  # Check mounted servers to see if they have the prompt
686
+ for prefix, mounted_server in self._mounted_servers.items():
687
+ if name.startswith(f"{prefix}_"):
688
+ prompt_name = name.removeprefix(f"{prefix}_")
689
+ return await mounted_server.server._mcp_get_prompt(
690
+ prompt_name, arguments
691
+ )
692
 
693
  raise NotFoundError(f"Unknown prompt: {name}")
694
 
 
1763
  return True
1764
 
1765
 
1766
+ @dataclass
1767
  class MountedServer:
1768
+ prefix: str
1769
+ server: FastMCP[Any]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1770
 
1771
 
1772
  def add_resource_prefix(
test_revert_check.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Quick test to verify the revert worked correctly."""
3
+
4
+ import asyncio
5
+
6
+ from fastmcp import FastMCP
7
+ from fastmcp.client import Client
8
+
9
+
10
+ async def test_empty_prefix_behavior():
11
+ """Test that empty prefix correctly adds underscore."""
12
+
13
+ main_app = FastMCP("MainApp")
14
+ sub_app = FastMCP("SubApp")
15
+
16
+ @sub_app.tool
17
+ def sub_tool() -> str:
18
+ return "This is from the sub app"
19
+
20
+ @sub_app.resource("data://test")
21
+ def sub_resource():
22
+ return "Resource data"
23
+
24
+ # Mount with empty prefix
25
+ main_app.mount("", sub_app)
26
+
27
+ # Check that tools have underscore prefix
28
+ tools = await main_app.get_tools()
29
+ print(f"Tools: {list(tools.keys())}")
30
+ assert "_sub_tool" in tools, f"Expected '_sub_tool' in {list(tools.keys())}"
31
+
32
+ # Check that resources work correctly
33
+ resources = await main_app.get_resources()
34
+ print(f"Resources: {list(resources.keys())}")
35
+ # Empty prefix for resources should result in no prefix change
36
+ assert "data://test" in resources, (
37
+ f"Expected 'data://test' in {list(resources.keys())}"
38
+ )
39
+
40
+ # Test calling the tool
41
+ async with Client(main_app) as client:
42
+ result = await client.call_tool("_sub_tool", {})
43
+ print(f"Tool result: {result[0].text}")
44
+ assert "This is from the sub app" in result[0].text
45
+
46
+ print("✅ Empty prefix correctly adds underscore for tools!")
47
+
48
+
49
+ if __name__ == "__main__":
50
+ asyncio.run(test_empty_prefix_behavior())
tests/deprecated/test_deprecated.py CHANGED
@@ -109,83 +109,3 @@ def test_from_client_deprecation_warning():
109
  server = FastMCP("TestServer")
110
  with pytest.warns(DeprecationWarning, match="from_client"):
111
  FastMCP.from_client(Client(server))
112
-
113
-
114
- def test_mount_tool_separator_deprecation_warning():
115
- """Test that using tool_separator in mount() raises a deprecation warning."""
116
- main_app = FastMCP("MainApp")
117
- sub_app = FastMCP("SubApp")
118
-
119
- with pytest.warns(
120
- DeprecationWarning,
121
- match="The tool_separator parameter is deprecated and will be removed in a future version",
122
- ):
123
- main_app.mount("sub", sub_app, tool_separator="-")
124
-
125
- # Verify the separator is ignored and the default is used
126
- @sub_app.tool
127
- def test_tool():
128
- return "test"
129
-
130
- mounted_server = main_app._mounted_servers["sub"]
131
- assert mounted_server.match_tool("sub_test_tool")
132
- assert not mounted_server.match_tool("sub-test_tool")
133
-
134
-
135
- def test_mount_resource_separator_deprecation_warning():
136
- """Test that using resource_separator in mount() raises a deprecation warning."""
137
- main_app = FastMCP("MainApp")
138
- sub_app = FastMCP("SubApp")
139
-
140
- with pytest.warns(
141
- DeprecationWarning,
142
- match="The resource_separator parameter is deprecated and ignored",
143
- ):
144
- main_app.mount("sub", sub_app, resource_separator="+")
145
-
146
-
147
- def test_mount_prompt_separator_deprecation_warning():
148
- """Test that using prompt_separator in mount() raises a deprecation warning."""
149
- main_app = FastMCP("MainApp")
150
- sub_app = FastMCP("SubApp")
151
-
152
- with pytest.warns(
153
- DeprecationWarning,
154
- match="The prompt_separator parameter is deprecated and will be removed in a future version",
155
- ):
156
- main_app.mount("sub", sub_app, prompt_separator="-")
157
-
158
- # Verify the separator is ignored and the default is used
159
- @sub_app.prompt
160
- def test_prompt():
161
- return "test"
162
-
163
- mounted_server = main_app._mounted_servers["sub"]
164
- assert mounted_server.match_prompt("sub_test_prompt")
165
- assert not mounted_server.match_prompt("sub-test_prompt")
166
-
167
-
168
- async def test_import_server_separator_deprecation_warnings():
169
- """Test that using separators in import_server() raises deprecation warnings."""
170
- main_app = FastMCP("MainApp")
171
- sub_app = FastMCP("SubApp")
172
-
173
- with pytest.warns(
174
- DeprecationWarning,
175
- match="The tool_separator parameter is deprecated and will be removed in a future version",
176
- ):
177
- await main_app.import_server("sub", sub_app, tool_separator="-")
178
-
179
- main_app = FastMCP("MainApp")
180
- with pytest.warns(
181
- DeprecationWarning,
182
- match="The resource_separator parameter is deprecated and ignored",
183
- ):
184
- await main_app.import_server("sub", sub_app, resource_separator="+")
185
-
186
- main_app = FastMCP("MainApp")
187
- with pytest.warns(
188
- DeprecationWarning,
189
- match="The prompt_separator parameter is deprecated and will be removed in a future version",
190
- ):
191
- await main_app.import_server("sub", sub_app, prompt_separator="-")
 
109
  server = FastMCP("TestServer")
110
  with pytest.warns(DeprecationWarning, match="from_client"):
111
  FastMCP.from_client(Client(server))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/deprecated/test_mount_separators.py CHANGED
@@ -1,14 +1,27 @@
1
  """Tests for the deprecated separator parameters in mount() and import_server() methods."""
2
 
3
  import pytest
 
4
 
5
- from fastmcp import FastMCP
6
 
7
  # reset deprecation warnings for this module
8
  pytestmark = pytest.mark.filterwarnings("default::DeprecationWarning")
9
 
10
 
11
- def test_mount_tool_separator_deprecation_warning():
 
 
 
 
 
 
 
 
 
 
 
 
12
  """Test that using tool_separator in mount() raises a deprecation warning."""
13
  main_app = FastMCP("MainApp")
14
  sub_app = FastMCP("SubApp")
@@ -24,24 +37,12 @@ def test_mount_tool_separator_deprecation_warning():
24
  def test_tool():
25
  return "test"
26
 
27
- mounted_server = main_app._mounted_servers["sub"]
28
- assert mounted_server.match_tool("sub_test_tool")
29
- assert not mounted_server.match_tool("sub-test_tool")
30
-
31
-
32
- def test_mount_resource_separator_deprecation_warning():
33
- """Test that using resource_separator in mount() raises a deprecation warning."""
34
- main_app = FastMCP("MainApp")
35
- sub_app = FastMCP("SubApp")
36
-
37
- with pytest.warns(
38
- DeprecationWarning,
39
- match="The resource_separator parameter is deprecated and ignored",
40
- ):
41
- main_app.mount("sub", sub_app, resource_separator="+")
42
 
43
 
44
- def test_mount_prompt_separator_deprecation_warning():
45
  """Test that using prompt_separator in mount() raises a deprecation warning."""
46
  main_app = FastMCP("MainApp")
47
  sub_app = FastMCP("SubApp")
@@ -57,9 +58,10 @@ def test_mount_prompt_separator_deprecation_warning():
57
  def test_prompt():
58
  return "test"
59
 
60
- mounted_server = main_app._mounted_servers["sub"]
61
- assert mounted_server.match_prompt("sub_test_prompt")
62
- assert not mounted_server.match_prompt("sub-test_prompt")
 
63
 
64
 
65
  async def test_import_server_separator_deprecation_warnings():
 
1
  """Tests for the deprecated separator parameters in mount() and import_server() methods."""
2
 
3
  import pytest
4
+ from mcp import McpError
5
 
6
+ from fastmcp import Client, FastMCP
7
 
8
  # reset deprecation warnings for this module
9
  pytestmark = pytest.mark.filterwarnings("default::DeprecationWarning")
10
 
11
 
12
+ def test_mount_resource_separator_deprecation_warning():
13
+ """Test that using resource_separator in mount() raises a deprecation warning."""
14
+ main_app = FastMCP("MainApp")
15
+ sub_app = FastMCP("SubApp")
16
+
17
+ with pytest.warns(
18
+ DeprecationWarning,
19
+ match="The resource_separator parameter is deprecated and ignored",
20
+ ):
21
+ main_app.mount("sub", sub_app, resource_separator="+")
22
+
23
+
24
+ async def test_mount_tool_separator_deprecation_warning():
25
  """Test that using tool_separator in mount() raises a deprecation warning."""
26
  main_app = FastMCP("MainApp")
27
  sub_app = FastMCP("SubApp")
 
37
  def test_tool():
38
  return "test"
39
 
40
+ async with Client(main_app) as client:
41
+ assert "sub_test_tool" in {t.name for t in await client.list_tools()}
42
+ assert "sub-test_tool" not in {t.name for t in await client.list_tools()}
 
 
 
 
 
 
 
 
 
 
 
 
43
 
44
 
45
+ async def test_mount_prompt_separator_deprecation_warning():
46
  """Test that using prompt_separator in mount() raises a deprecation warning."""
47
  main_app = FastMCP("MainApp")
48
  sub_app = FastMCP("SubApp")
 
58
  def test_prompt():
59
  return "test"
60
 
61
+ async with Client(main_app) as client:
62
+ assert await client.get_prompt("sub_test_prompt")
63
+ with pytest.raises(McpError, match="Unknown prompt"):
64
+ await client.get_prompt("sub-test_prompt")
65
 
66
 
67
  async def test_import_server_separator_deprecation_warnings():
tests/server/test_server.py CHANGED
@@ -9,7 +9,6 @@ from fastmcp.exceptions import NotFoundError
9
  from fastmcp.prompts.prompt import FunctionPrompt, Prompt
10
  from fastmcp.resources import Resource, ResourceTemplate
11
  from fastmcp.server.server import (
12
- MountedServer,
13
  add_resource_prefix,
14
  has_resource_prefix,
15
  remove_resource_prefix,
@@ -1183,16 +1182,23 @@ class TestResourcePrefixMounting:
1183
  async def test_mounted_server_matching_and_stripping(
1184
  self, uri, prefix, expected_match, expected_strip
1185
  ):
1186
- """Test that MountedServer correctly matches and strips resource prefixes."""
1187
- # Create a basic server to mount
 
 
1188
  server = FastMCP()
1189
- mounted = MountedServer(prefix=prefix, server=server)
1190
 
1191
  # Test matching
1192
- assert mounted.match_resource(uri) == expected_match
 
 
 
1193
 
1194
  # Test stripping
1195
- assert mounted.strip_resource_prefix(uri) == expected_strip
 
 
 
1196
 
1197
  async def test_import_server_with_new_prefix_format(self):
1198
  """Test that import_server correctly uses the new prefix format."""
 
9
  from fastmcp.prompts.prompt import FunctionPrompt, Prompt
10
  from fastmcp.resources import Resource, ResourceTemplate
11
  from fastmcp.server.server import (
 
12
  add_resource_prefix,
13
  has_resource_prefix,
14
  remove_resource_prefix,
 
1182
  async def test_mounted_server_matching_and_stripping(
1183
  self, uri, prefix, expected_match, expected_strip
1184
  ):
1185
+ """Test that resource prefix utility functions correctly match and strip resource prefixes."""
1186
+ from fastmcp.server.server import has_resource_prefix, remove_resource_prefix
1187
+
1188
+ # Create a basic server to get the default resource prefix format
1189
  server = FastMCP()
 
1190
 
1191
  # Test matching
1192
+ assert (
1193
+ has_resource_prefix(uri, prefix, server.resource_prefix_format)
1194
+ == expected_match
1195
+ )
1196
 
1197
  # Test stripping
1198
+ assert (
1199
+ remove_resource_prefix(uri, prefix, server.resource_prefix_format)
1200
+ == expected_strip
1201
+ )
1202
 
1203
  async def test_import_server_with_new_prefix_format(self):
1204
  """Test that import_server correctly uses the new prefix format."""