diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 09b4815b6..ef50374a6 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -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( diff --git a/test_revert_check.py b/test_revert_check.py new file mode 100644 index 000000000..a9e360e1e --- /dev/null +++ b/test_revert_check.py @@ -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()) diff --git a/tests/deprecated/test_deprecated.py b/tests/deprecated/test_deprecated.py index a20b970bb..f71161a98 100644 --- a/tests/deprecated/test_deprecated.py +++ b/tests/deprecated/test_deprecated.py @@ -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="-") diff --git a/tests/deprecated/test_mount_separators.py b/tests/deprecated/test_mount_separators.py index 85ec63436..cabefc963 100644 --- a/tests/deprecated/test_mount_separators.py +++ b/tests/deprecated/test_mount_separators.py @@ -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(): diff --git a/tests/server/test_server.py b/tests/server/test_server.py index dd4719a6e..70a2c174b 100644 --- a/tests/server/test_server.py +++ b/tests/server/test_server.py @@ -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."""