Skip to content

Commit 080ffa5

Browse files
authored
Fix nested server mount routing for 3+ levels deep (#2586)
Tools, resources, and prompts from servers mounted more than 2 levels deep failed to invoke even though they were correctly listed. The bug was in the routing methods which used manager methods that only search locally, not through nested mounted servers. Changed to use server-level methods that search recursively. Fixes #2583
1 parent 0bcd69c commit 080ffa5

File tree

2 files changed

+158
-16
lines changed

2 files changed

+158
-16
lines changed

src/fastmcp/server/server.py

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -691,7 +691,7 @@ async def handler(req: mcp.types.ReadResourceRequest) -> mcp.types.ServerResult:
691691
# Check for task metadata and route appropriately
692692
async with fastmcp.server.context.Context(fastmcp=self):
693693
# Get resource including from mounted servers
694-
resource = await self._get_resource_with_task_config(str(uri))
694+
resource = await self._get_resource_or_template_or_none(str(uri))
695695
if (
696696
resource
697697
and self._should_enable_component(resource)
@@ -967,21 +967,15 @@ async def _get_tool_with_task_config(self, key: str) -> Tool | None:
967967
except NotFoundError:
968968
return None
969969

970-
async def _get_resource_with_task_config(
970+
async def _get_resource_or_template_or_none(
971971
self, uri: str
972972
) -> Resource | ResourceTemplate | None:
973-
"""Get a resource or template by URI, returning None if not found.
974-
975-
Used for task config checking where we need the actual resource object
976-
(including from mounted servers) but don't want to raise.
977-
"""
978-
# Try exact resource match first
973+
"""Get a resource or template by URI, searching recursively. Returns None if not found."""
979974
try:
980975
return await self.get_resource(uri)
981976
except NotFoundError:
982977
pass
983978

984-
# Try resource templates for URI pattern matching
985979
templates = await self.get_resource_templates()
986980
for template in templates.values():
987981
if template.matches(uri):
@@ -1645,7 +1639,9 @@ async def _call_tool(
16451639

16461640
try:
16471641
# First, get the tool to check if parent's filter allows it
1648-
tool = await mounted.server._tool_manager.get_tool(try_name)
1642+
# Use get_tool() instead of _tool_manager.get_tool() to support
1643+
# nested mounted servers (tools mounted more than 2 levels deep)
1644+
tool = await mounted.server.get_tool(try_name)
16491645
if not self._should_enable_component(tool):
16501646
# Parent filter blocks this tool, continue searching
16511647
continue
@@ -1730,12 +1726,16 @@ async def _read_resource(
17301726
continue
17311727
key = remove_resource_prefix(key, mounted.prefix)
17321728

1729+
# First, get the resource/template to check if parent's filter allows it
1730+
# Use get_resource_or_template to support nested mounted servers
1731+
# (resources/templates mounted more than 2 levels deep)
1732+
resource = await mounted.server._get_resource_or_template_or_none(key)
1733+
if resource is None:
1734+
continue
1735+
if not self._should_enable_component(resource):
1736+
# Parent filter blocks this resource, continue searching
1737+
continue
17331738
try:
1734-
# First, get the resource to check if parent's filter allows it
1735-
resource = await mounted.server._resource_manager.get_resource(key)
1736-
if not self._should_enable_component(resource):
1737-
# Parent filter blocks this resource, continue searching
1738-
continue
17391739
result = list(await mounted.server._read_resource_middleware(key))
17401740
return result
17411741
except NotFoundError:
@@ -1816,7 +1816,9 @@ async def _get_prompt(
18161816

18171817
try:
18181818
# First, get the prompt to check if parent's filter allows it
1819-
prompt = await mounted.server._prompt_manager.get_prompt(try_name)
1819+
# Use get_prompt() instead of _prompt_manager.get_prompt() to support
1820+
# nested mounted servers (prompts mounted more than 2 levels deep)
1821+
prompt = await mounted.server.get_prompt(try_name)
18201822
if not self._should_enable_component(prompt):
18211823
# Parent filter blocks this prompt, continue searching
18221824
continue

tests/server/test_mount.py

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1221,3 +1221,143 @@ async def route2(request):
12211221
route_paths = [route.path for route in routes] # type: ignore[attr-defined]
12221222
assert "/route1" in route_paths
12231223
assert "/route2" in route_paths
1224+
1225+
1226+
class TestDeeplyNestedMount:
1227+
"""Test deeply nested mount scenarios (3+ levels deep).
1228+
1229+
This tests the fix for https://github.com/jlowin/fastmcp/issues/2583
1230+
where tools/resources/prompts mounted more than 2 levels deep would fail
1231+
to invoke even though they were correctly listed.
1232+
"""
1233+
1234+
async def test_three_level_nested_tool_invocation(self):
1235+
"""Test invoking tools from servers mounted 3 levels deep."""
1236+
root = FastMCP("root")
1237+
middle = FastMCP("middle")
1238+
leaf = FastMCP("leaf")
1239+
1240+
@leaf.tool
1241+
def add(a: int, b: int) -> int:
1242+
return a + b
1243+
1244+
@middle.tool
1245+
def multiply(a: int, b: int) -> int:
1246+
return a * b
1247+
1248+
middle.mount(leaf, prefix="leaf")
1249+
root.mount(middle, prefix="middle")
1250+
1251+
async with Client(root) as client:
1252+
# Tool at level 2 should work
1253+
result = await client.call_tool("middle_multiply", {"a": 3, "b": 4})
1254+
assert result.data == 12
1255+
1256+
# Tool at level 3 should also work (this was the bug)
1257+
result = await client.call_tool("middle_leaf_add", {"a": 5, "b": 7})
1258+
assert result.data == 12
1259+
1260+
async def test_three_level_nested_resource_invocation(self):
1261+
"""Test reading resources from servers mounted 3 levels deep."""
1262+
root = FastMCP("root")
1263+
middle = FastMCP("middle")
1264+
leaf = FastMCP("leaf")
1265+
1266+
@leaf.resource("leaf://data")
1267+
def leaf_data() -> str:
1268+
return "leaf data"
1269+
1270+
@middle.resource("middle://data")
1271+
def middle_data() -> str:
1272+
return "middle data"
1273+
1274+
middle.mount(leaf, prefix="leaf")
1275+
root.mount(middle, prefix="middle")
1276+
1277+
async with Client(root) as client:
1278+
# Resource at level 2 should work
1279+
result = await client.read_resource("middle://middle/data")
1280+
assert result[0].text == "middle data"
1281+
1282+
# Resource at level 3 should also work
1283+
result = await client.read_resource("leaf://middle/leaf/data")
1284+
assert result[0].text == "leaf data"
1285+
1286+
async def test_three_level_nested_resource_template_invocation(self):
1287+
"""Test reading resource templates from servers mounted 3 levels deep."""
1288+
root = FastMCP("root")
1289+
middle = FastMCP("middle")
1290+
leaf = FastMCP("leaf")
1291+
1292+
@leaf.resource("leaf://item/{id}")
1293+
def leaf_item(id: str) -> str:
1294+
return f"leaf item {id}"
1295+
1296+
@middle.resource("middle://item/{id}")
1297+
def middle_item(id: str) -> str:
1298+
return f"middle item {id}"
1299+
1300+
middle.mount(leaf, prefix="leaf")
1301+
root.mount(middle, prefix="middle")
1302+
1303+
async with Client(root) as client:
1304+
# Resource template at level 2 should work
1305+
result = await client.read_resource("middle://middle/item/42")
1306+
assert result[0].text == "middle item 42"
1307+
1308+
# Resource template at level 3 should also work
1309+
result = await client.read_resource("leaf://middle/leaf/item/99")
1310+
assert result[0].text == "leaf item 99"
1311+
1312+
async def test_three_level_nested_prompt_invocation(self):
1313+
"""Test getting prompts from servers mounted 3 levels deep."""
1314+
root = FastMCP("root")
1315+
middle = FastMCP("middle")
1316+
leaf = FastMCP("leaf")
1317+
1318+
@leaf.prompt
1319+
def leaf_prompt(name: str) -> str:
1320+
return f"Hello from leaf: {name}"
1321+
1322+
@middle.prompt
1323+
def middle_prompt(name: str) -> str:
1324+
return f"Hello from middle: {name}"
1325+
1326+
middle.mount(leaf, prefix="leaf")
1327+
root.mount(middle, prefix="middle")
1328+
1329+
async with Client(root) as client:
1330+
# Prompt at level 2 should work
1331+
result = await client.get_prompt("middle_middle_prompt", {"name": "World"})
1332+
assert "Hello from middle: World" in result.messages[0].content.text # type: ignore[union-attr]
1333+
1334+
# Prompt at level 3 should also work
1335+
result = await client.get_prompt(
1336+
"middle_leaf_leaf_prompt", {"name": "Test"}
1337+
)
1338+
assert "Hello from leaf: Test" in result.messages[0].content.text # type: ignore[union-attr]
1339+
1340+
async def test_four_level_nested_tool_invocation(self):
1341+
"""Test invoking tools from servers mounted 4 levels deep."""
1342+
root = FastMCP("root")
1343+
level1 = FastMCP("level1")
1344+
level2 = FastMCP("level2")
1345+
level3 = FastMCP("level3")
1346+
1347+
@level3.tool
1348+
def deep_tool() -> str:
1349+
return "very deep"
1350+
1351+
level2.mount(level3, prefix="l3")
1352+
level1.mount(level2, prefix="l2")
1353+
root.mount(level1, prefix="l1")
1354+
1355+
async with Client(root) as client:
1356+
# Verify tool is listed
1357+
tools = await client.list_tools()
1358+
tool_names = [t.name for t in tools]
1359+
assert "l1_l2_l3_deep_tool" in tool_names
1360+
1361+
# Tool at level 4 should work
1362+
result = await client.call_tool("l1_l2_l3_deep_tool", {})
1363+
assert result.data == "very deep"

0 commit comments

Comments
 (0)