diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 89d760370..f5e35916b 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -257,9 +257,15 @@ 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 server in self._mounted_servers.values(): - server_tools = await server.get_tools() - tools.update(server_tools) + for prefix, server in self._mounted_servers.items(): + try: + server_tools = await server.get_tools() + tools.update(server_tools) + except Exception as e: + logger.warning( + f"Failed to get tools from mounted server '{prefix}': {e}" + ) + continue tools.update(self._tool_manager.get_tools()) self._cache.set("tools", tools) return tools @@ -268,9 +274,15 @@ 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 server in self._mounted_servers.values(): - server_resources = await server.get_resources() - resources.update(server_resources) + for prefix, server in self._mounted_servers.items(): + try: + server_resources = await server.get_resources() + resources.update(server_resources) + except Exception as e: + logger.warning( + f"Failed to get resources from mounted server '{prefix}': {e}" + ) + continue resources.update(self._resource_manager.get_resources()) self._cache.set("resources", resources) return resources @@ -281,9 +293,16 @@ class FastMCP(Generic[LifespanResultT]): templates := self._cache.get("resource_templates") ) is self._cache.NOT_FOUND: templates: dict[str, ResourceTemplate] = {} - for server in self._mounted_servers.values(): - server_templates = await server.get_resource_templates() - templates.update(server_templates) + for prefix, server in self._mounted_servers.items(): + try: + server_templates = await server.get_resource_templates() + templates.update(server_templates) + except Exception as e: + logger.warning( + "Failed to get resource templates from mounted server " + f"'{prefix}': {e}" + ) + continue templates.update(self._resource_manager.get_templates()) self._cache.set("resource_templates", templates) return templates @@ -294,9 +313,15 @@ class FastMCP(Generic[LifespanResultT]): """ if (prompts := self._cache.get("prompts")) is self._cache.NOT_FOUND: prompts: dict[str, Prompt] = {} - for server in self._mounted_servers.values(): - server_prompts = await server.get_prompts() - prompts.update(server_prompts) + for prefix, server in self._mounted_servers.items(): + try: + server_prompts = await server.get_prompts() + prompts.update(server_prompts) + except Exception as e: + logger.warning( + f"Failed to get prompts from mounted server '{prefix}': {e}" + ) + continue prompts.update(self._prompt_manager.get_prompts()) self._cache.set("prompts", prompts) return prompts diff --git a/tests/server/test_mount.py b/tests/server/test_mount.py index 598cf3aa0..a04a14edf 100644 --- a/tests/server/test_mount.py +++ b/tests/server/test_mount.py @@ -1,4 +1,5 @@ import json +import sys from contextlib import asynccontextmanager import pytest @@ -7,7 +8,7 @@ from mcp.types import TextContent, TextResourceContents from fastmcp import FastMCP from fastmcp.client import Client -from fastmcp.client.transports import FastMCPTransport +from fastmcp.client.transports import FastMCPTransport, SSETransport from fastmcp.exceptions import NotFoundError from fastmcp.server.proxy import FastMCPProxy @@ -182,6 +183,80 @@ class TestMultipleServerMount: # Second app's tool should be accessible assert "api_second_tool" in tools + @pytest.mark.skipif( + sys.platform == "win32", reason="Windows asyncio networking timeouts." + ) + async def test_mount_with_unreachable_proxy_servers(self, caplog): + """Test graceful handling when multiple mounted servers fail to connect.""" + + main_app = FastMCP("MainApp") + working_app = FastMCP("WorkingApp") + + @working_app.tool() + def working_tool() -> str: + return "Working tool" + + @working_app.resource(uri="working://data") + def working_resource(): + return "Working resource" + + @working_app.prompt() + def working_prompt() -> str: + return "Working prompt" + + # Mount the working server + main_app.mount("working", working_app) + + # Use an unreachable port + unreachable_client = Client( + transport=SSETransport("http://127.0.0.1:99999/sse") + ) + + # Create a proxy server that will fail to connect + unreachable_proxy = FastMCP.as_proxy(unreachable_client) + + # Mount the unreachable proxy + main_app.mount("unreachable", unreachable_proxy) + + # All object types should work from working server despite unreachable proxy + async with Client(main_app) as client: + # Test tools + tools = await client.list_tools() + tool_names = [tool.name for tool in tools] + assert "working_working_tool" in tool_names + + # Test calling a tool + result = await client.call_tool("working_working_tool", {}) + assert isinstance(result[0], TextContent) + assert result[0].text == "Working tool" + + # Test resources + resources = await client.list_resources() + resource_uris = [str(resource.uri) for resource in resources] + assert "working://working/data" in resource_uris + + # Test prompts + prompts = await client.list_prompts() + prompt_names = [prompt.name for prompt in prompts] + assert "working_working_prompt" in prompt_names + + # Verify that warnings were logged for the unreachable server + warning_messages = [ + record.message for record in caplog.records if record.levelname == "WARNING" + ] + assert any( + "Failed to get tools from mounted server 'unreachable'" in msg + for msg in warning_messages + ) + assert any( + "Failed to get resources from mounted server 'unreachable'" in msg + for msg in warning_messages + ) + assert any( + "Failed to get prompts from mounted server 'unreachable'" in msg + for msg in warning_messages + ) + class TestDynamicChanges: """Test that changes to mounted servers are reflected dynamically."""