Consolidate prefix logic

This commit is contained in:
Jeremiah Lowin 2025-06-17 20:23:47 -04:00
commit 084e52badd
5 changed files with 144 additions and 184 deletions

View file

@ -12,6 +12,7 @@ from contextlib import (
AsyncExitStack,
asynccontextmanager,
)
from dataclasses import dataclass
from functools import partial
from pathlib import Path
from typing import TYPE_CHECKING, Any, Generic, Literal, overload
@ -322,10 +323,14 @@ class FastMCP(Generic[LifespanResultT]):
"""Get all registered tools, indexed by registered key."""
if (tools := self._cache.get("tools")) is self._cache.NOT_FOUND:
tools: dict[str, Tool] = {}
for prefix, server in self._mounted_servers.items():
for prefix, mounted_server in self._mounted_servers.items():
try:
server_tools = await server.get_tools()
tools.update(server_tools)
server_tools = await mounted_server.server.get_tools()
# Apply prefix to each tool key
prefixed_tools = {
f"{prefix}_{key}": tool for key, tool in server_tools.items()
}
tools.update(prefixed_tools)
except Exception as e:
logger.warning(
f"Failed to get tools from mounted server '{prefix}': {e}"
@ -345,10 +350,17 @@ class FastMCP(Generic[LifespanResultT]):
"""Get all registered resources, indexed by registered key."""
if (resources := self._cache.get("resources")) is self._cache.NOT_FOUND:
resources: dict[str, Resource] = {}
for prefix, server in self._mounted_servers.items():
for prefix, mounted_server in self._mounted_servers.items():
try:
server_resources = await server.get_resources()
resources.update(server_resources)
server_resources = await mounted_server.server.get_resources()
# Apply prefix to each resource key
prefixed_resources = {
add_resource_prefix(
key, prefix, mounted_server.server.resource_prefix_format
): resource
for key, resource in server_resources.items()
}
resources.update(prefixed_resources)
except Exception as e:
logger.warning(
f"Failed to get resources from mounted server '{prefix}': {e}"
@ -370,10 +382,19 @@ class FastMCP(Generic[LifespanResultT]):
templates := self._cache.get("resource_templates")
) is self._cache.NOT_FOUND:
templates: dict[str, ResourceTemplate] = {}
for prefix, server in self._mounted_servers.items():
for prefix, mounted_server in self._mounted_servers.items():
try:
server_templates = await server.get_resource_templates()
templates.update(server_templates)
server_templates = (
await mounted_server.server.get_resource_templates()
)
# Apply prefix to each template key
prefixed_templates = {
add_resource_prefix(
key, prefix, mounted_server.server.resource_prefix_format
): template
for key, template in server_templates.items()
}
templates.update(prefixed_templates)
except Exception as e:
logger.warning(
"Failed to get resource templates from mounted server "
@ -396,10 +417,15 @@ class FastMCP(Generic[LifespanResultT]):
"""
if (prompts := self._cache.get("prompts")) is self._cache.NOT_FOUND:
prompts: dict[str, Prompt] = {}
for prefix, server in self._mounted_servers.items():
for prefix, mounted_server in self._mounted_servers.items():
try:
server_prompts = await server.get_prompts()
prompts.update(server_prompts)
server_prompts = await mounted_server.server.get_prompts()
# Apply prefix to each prompt key
prefixed_prompts = {
f"{prefix}_{key}": prompt
for key, prompt in server_prompts.items()
}
prompts.update(prefixed_prompts)
except Exception as e:
logger.warning(
f"Failed to get prompts from mounted server '{prefix}': {e}"
@ -562,10 +588,10 @@ class FastMCP(Generic[LifespanResultT]):
return await self._tool_manager.call_tool(key, arguments)
# Check mounted servers to see if they have the tool
for server in self._mounted_servers.values():
if server.match_tool(key):
tool_key = server.strip_tool_prefix(key)
return await server.server._call_tool(tool_key, arguments)
for prefix, mounted_server in self._mounted_servers.items():
if key.startswith(f"{prefix}_"):
tool_key = key.removeprefix(f"{prefix}_")
return await mounted_server.server._call_tool(tool_key, arguments)
raise NotFoundError(f"Unknown tool: {key!r}")
@ -604,10 +630,14 @@ class FastMCP(Generic[LifespanResultT]):
)
]
else:
for server in self._mounted_servers.values():
if server.match_resource(str(uri)):
new_uri = server.strip_resource_prefix(str(uri))
return await server.server._mcp_read_resource(new_uri)
for prefix, mounted_server in self._mounted_servers.items():
if has_resource_prefix(
str(uri), prefix, mounted_server.server.resource_prefix_format
):
new_uri = remove_resource_prefix(
str(uri), prefix, mounted_server.server.resource_prefix_format
)
return await mounted_server.server._mcp_read_resource(new_uri)
else:
raise NotFoundError(f"Unknown resource: {uri}")
@ -653,10 +683,12 @@ class FastMCP(Generic[LifespanResultT]):
return await self._prompt_manager.render_prompt(name, arguments)
# Check mounted servers to see if they have the prompt
for server in self._mounted_servers.values():
if server.match_prompt(name):
prompt_name = server.strip_prompt_prefix(name)
return await server.server._mcp_get_prompt(prompt_name, arguments)
for prefix, mounted_server in self._mounted_servers.items():
if name.startswith(f"{prefix}_"):
prompt_name = name.removeprefix(f"{prefix}_")
return await mounted_server.server._mcp_get_prompt(
prompt_name, arguments
)
raise NotFoundError(f"Unknown prompt: {name}")
@ -1731,60 +1763,10 @@ class FastMCP(Generic[LifespanResultT]):
return True
@dataclass
class MountedServer:
def __init__(
self,
prefix: str,
server: FastMCP[LifespanResultT],
):
self.server = server
self.prefix = prefix
async def get_tools(self) -> dict[str, Tool]:
tools = await self.server.get_tools()
return {f"{self.prefix}_{key}": tool for key, tool in tools.items()}
async def get_resources(self) -> dict[str, Resource]:
resources = await self.server.get_resources()
return {
add_resource_prefix(
key, self.prefix, self.server.resource_prefix_format
): resource
for key, resource in resources.items()
}
async def get_resource_templates(self) -> dict[str, ResourceTemplate]:
templates = await self.server.get_resource_templates()
return {
add_resource_prefix(
key, self.prefix, self.server.resource_prefix_format
): template
for key, template in templates.items()
}
async def get_prompts(self) -> dict[str, Prompt]:
prompts = await self.server.get_prompts()
return {f"{self.prefix}_{key}": prompt for key, prompt in prompts.items()}
def match_tool(self, key: str) -> bool:
return key.startswith(f"{self.prefix}_")
def strip_tool_prefix(self, key: str) -> str:
return key.removeprefix(f"{self.prefix}_")
def match_resource(self, key: str) -> bool:
return has_resource_prefix(key, self.prefix, self.server.resource_prefix_format)
def strip_resource_prefix(self, key: str) -> str:
return remove_resource_prefix(
key, self.prefix, self.server.resource_prefix_format
)
def match_prompt(self, key: str) -> bool:
return key.startswith(f"{self.prefix}_")
def strip_prompt_prefix(self, key: str) -> str:
return key.removeprefix(f"{self.prefix}_")
prefix: str
server: FastMCP[Any]
def add_resource_prefix(

50
test_revert_check.py Normal file
View file

@ -0,0 +1,50 @@
#!/usr/bin/env python3
"""Quick test to verify the revert worked correctly."""
import asyncio
from fastmcp import FastMCP
from fastmcp.client import Client
async def test_empty_prefix_behavior():
"""Test that empty prefix correctly adds underscore."""
main_app = FastMCP("MainApp")
sub_app = FastMCP("SubApp")
@sub_app.tool
def sub_tool() -> str:
return "This is from the sub app"
@sub_app.resource("data://test")
def sub_resource():
return "Resource data"
# Mount with empty prefix
main_app.mount("", sub_app)
# Check that tools have underscore prefix
tools = await main_app.get_tools()
print(f"Tools: {list(tools.keys())}")
assert "_sub_tool" in tools, f"Expected '_sub_tool' in {list(tools.keys())}"
# Check that resources work correctly
resources = await main_app.get_resources()
print(f"Resources: {list(resources.keys())}")
# Empty prefix for resources should result in no prefix change
assert "data://test" in resources, (
f"Expected 'data://test' in {list(resources.keys())}"
)
# Test calling the tool
async with Client(main_app) as client:
result = await client.call_tool("_sub_tool", {})
print(f"Tool result: {result[0].text}")
assert "This is from the sub app" in result[0].text
print("✅ Empty prefix correctly adds underscore for tools!")
if __name__ == "__main__":
asyncio.run(test_empty_prefix_behavior())

View file

@ -109,83 +109,3 @@ def test_from_client_deprecation_warning():
server = FastMCP("TestServer")
with pytest.warns(DeprecationWarning, match="from_client"):
FastMCP.from_client(Client(server))
def test_mount_tool_separator_deprecation_warning():
"""Test that using tool_separator in mount() raises a deprecation warning."""
main_app = FastMCP("MainApp")
sub_app = FastMCP("SubApp")
with pytest.warns(
DeprecationWarning,
match="The tool_separator parameter is deprecated and will be removed in a future version",
):
main_app.mount("sub", sub_app, tool_separator="-")
# Verify the separator is ignored and the default is used
@sub_app.tool
def test_tool():
return "test"
mounted_server = main_app._mounted_servers["sub"]
assert mounted_server.match_tool("sub_test_tool")
assert not mounted_server.match_tool("sub-test_tool")
def test_mount_resource_separator_deprecation_warning():
"""Test that using resource_separator in mount() raises a deprecation warning."""
main_app = FastMCP("MainApp")
sub_app = FastMCP("SubApp")
with pytest.warns(
DeprecationWarning,
match="The resource_separator parameter is deprecated and ignored",
):
main_app.mount("sub", sub_app, resource_separator="+")
def test_mount_prompt_separator_deprecation_warning():
"""Test that using prompt_separator in mount() raises a deprecation warning."""
main_app = FastMCP("MainApp")
sub_app = FastMCP("SubApp")
with pytest.warns(
DeprecationWarning,
match="The prompt_separator parameter is deprecated and will be removed in a future version",
):
main_app.mount("sub", sub_app, prompt_separator="-")
# Verify the separator is ignored and the default is used
@sub_app.prompt
def test_prompt():
return "test"
mounted_server = main_app._mounted_servers["sub"]
assert mounted_server.match_prompt("sub_test_prompt")
assert not mounted_server.match_prompt("sub-test_prompt")
async def test_import_server_separator_deprecation_warnings():
"""Test that using separators in import_server() raises deprecation warnings."""
main_app = FastMCP("MainApp")
sub_app = FastMCP("SubApp")
with pytest.warns(
DeprecationWarning,
match="The tool_separator parameter is deprecated and will be removed in a future version",
):
await main_app.import_server("sub", sub_app, tool_separator="-")
main_app = FastMCP("MainApp")
with pytest.warns(
DeprecationWarning,
match="The resource_separator parameter is deprecated and ignored",
):
await main_app.import_server("sub", sub_app, resource_separator="+")
main_app = FastMCP("MainApp")
with pytest.warns(
DeprecationWarning,
match="The prompt_separator parameter is deprecated and will be removed in a future version",
):
await main_app.import_server("sub", sub_app, prompt_separator="-")

View file

@ -1,14 +1,27 @@
"""Tests for the deprecated separator parameters in mount() and import_server() methods."""
import pytest
from mcp import McpError
from fastmcp import FastMCP
from fastmcp import Client, FastMCP
# reset deprecation warnings for this module
pytestmark = pytest.mark.filterwarnings("default::DeprecationWarning")
def test_mount_tool_separator_deprecation_warning():
def test_mount_resource_separator_deprecation_warning():
"""Test that using resource_separator in mount() raises a deprecation warning."""
main_app = FastMCP("MainApp")
sub_app = FastMCP("SubApp")
with pytest.warns(
DeprecationWarning,
match="The resource_separator parameter is deprecated and ignored",
):
main_app.mount("sub", sub_app, resource_separator="+")
async def test_mount_tool_separator_deprecation_warning():
"""Test that using tool_separator in mount() raises a deprecation warning."""
main_app = FastMCP("MainApp")
sub_app = FastMCP("SubApp")
@ -24,24 +37,12 @@ def test_mount_tool_separator_deprecation_warning():
def test_tool():
return "test"
mounted_server = main_app._mounted_servers["sub"]
assert mounted_server.match_tool("sub_test_tool")
assert not mounted_server.match_tool("sub-test_tool")
async with Client(main_app) as client:
assert "sub_test_tool" in {t.name for t in await client.list_tools()}
assert "sub-test_tool" not in {t.name for t in await client.list_tools()}
def test_mount_resource_separator_deprecation_warning():
"""Test that using resource_separator in mount() raises a deprecation warning."""
main_app = FastMCP("MainApp")
sub_app = FastMCP("SubApp")
with pytest.warns(
DeprecationWarning,
match="The resource_separator parameter is deprecated and ignored",
):
main_app.mount("sub", sub_app, resource_separator="+")
def test_mount_prompt_separator_deprecation_warning():
async def test_mount_prompt_separator_deprecation_warning():
"""Test that using prompt_separator in mount() raises a deprecation warning."""
main_app = FastMCP("MainApp")
sub_app = FastMCP("SubApp")
@ -57,9 +58,10 @@ def test_mount_prompt_separator_deprecation_warning():
def test_prompt():
return "test"
mounted_server = main_app._mounted_servers["sub"]
assert mounted_server.match_prompt("sub_test_prompt")
assert not mounted_server.match_prompt("sub-test_prompt")
async with Client(main_app) as client:
assert await client.get_prompt("sub_test_prompt")
with pytest.raises(McpError, match="Unknown prompt"):
await client.get_prompt("sub-test_prompt")
async def test_import_server_separator_deprecation_warnings():

View file

@ -9,7 +9,6 @@ from fastmcp.exceptions import NotFoundError
from fastmcp.prompts.prompt import FunctionPrompt, Prompt
from fastmcp.resources import Resource, ResourceTemplate
from fastmcp.server.server import (
MountedServer,
add_resource_prefix,
has_resource_prefix,
remove_resource_prefix,
@ -1183,16 +1182,23 @@ class TestResourcePrefixMounting:
async def test_mounted_server_matching_and_stripping(
self, uri, prefix, expected_match, expected_strip
):
"""Test that MountedServer correctly matches and strips resource prefixes."""
# Create a basic server to mount
"""Test that resource prefix utility functions correctly match and strip resource prefixes."""
from fastmcp.server.server import has_resource_prefix, remove_resource_prefix
# Create a basic server to get the default resource prefix format
server = FastMCP()
mounted = MountedServer(prefix=prefix, server=server)
# Test matching
assert mounted.match_resource(uri) == expected_match
assert (
has_resource_prefix(uri, prefix, server.resource_prefix_format)
== expected_match
)
# Test stripping
assert mounted.strip_resource_prefix(uri) == expected_strip
assert (
remove_resource_prefix(uri, prefix, server.resource_prefix_format)
== expected_strip
)
async def test_import_server_with_new_prefix_format(self):
"""Test that import_server correctly uses the new prefix format."""