From b7607af17a57be061704a51049def8e6b536c42f Mon Sep 17 00:00:00 2001 From: davenpi Date: Mon, 26 May 2025 17:42:23 -0400 Subject: [PATCH 1/2] Handle unreachable mounted servers. --- src/fastmcp/server/server.py | 94 +++++++++++++++++++++++++++--------- tests/server/test_mount.py | 77 ++++++++++++++++++++++++++++- 2 files changed, 146 insertions(+), 25 deletions(-) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 89d760370..2df709b6d 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 @@ -407,10 +432,16 @@ 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._mcp_call_tool(tool_key, arguments) + for prefix, server in self._mounted_servers.items(): + try: + if server.match_tool(key): + tool_key = server.strip_tool_prefix(key) + return await server.server._mcp_call_tool(tool_key, arguments) + except Exception as e: + logger.warning( + f"Failed to call tool from mounted server '{prefix}': {e}" + ) + continue raise NotFoundError(f"Unknown tool: {key}") @@ -430,10 +461,17 @@ 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, server in self._mounted_servers.items(): + try: + if server.match_resource(str(uri)): + new_uri = server.strip_resource_prefix(str(uri)) + return await server.server._mcp_read_resource(new_uri) + except Exception as e: + logger.warning( + "Failed to read resource from mounted server" + f" '{prefix}': {e}" + ) + continue else: raise NotFoundError(f"Unknown resource: {uri}") @@ -458,10 +496,18 @@ 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, server in self._mounted_servers.items(): + try: + if server.match_prompt(name): + prompt_name = server.strip_prompt_prefix(name) + return await server.server._mcp_get_prompt( + prompt_name, arguments + ) + except Exception as e: + logger.warning( + f"Failed to get prompt from mounted server '{prefix}': {e}" + ) + continue raise NotFoundError(f"Unknown prompt: {name}") 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.""" From bfef774c111f654efbd82be0cca9294a3ff3160e Mon Sep 17 00:00:00 2001 From: davenpi Date: Tue, 27 May 2025 09:24:57 -0400 Subject: [PATCH 2/2] Remove exceptions from execution methods. --- src/fastmcp/server/server.py | 45 ++++++++++-------------------------- 1 file changed, 12 insertions(+), 33 deletions(-) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index 2df709b6d..f5e35916b 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -432,16 +432,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 prefix, server in self._mounted_servers.items(): - try: - if server.match_tool(key): - tool_key = server.strip_tool_prefix(key) - return await server.server._mcp_call_tool(tool_key, arguments) - except Exception as e: - logger.warning( - f"Failed to call tool from mounted server '{prefix}': {e}" - ) - continue + for server in self._mounted_servers.values(): + if server.match_tool(key): + tool_key = server.strip_tool_prefix(key) + return await server.server._mcp_call_tool(tool_key, arguments) raise NotFoundError(f"Unknown tool: {key}") @@ -461,17 +455,10 @@ class FastMCP(Generic[LifespanResultT]): ) ] else: - for prefix, server in self._mounted_servers.items(): - try: - if server.match_resource(str(uri)): - new_uri = server.strip_resource_prefix(str(uri)) - return await server.server._mcp_read_resource(new_uri) - except Exception as e: - logger.warning( - "Failed to read resource from mounted server" - f" '{prefix}': {e}" - ) - continue + 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) else: raise NotFoundError(f"Unknown resource: {uri}") @@ -496,18 +483,10 @@ class FastMCP(Generic[LifespanResultT]): return await self._prompt_manager.render_prompt(name, arguments) # Check mounted servers to see if they have the prompt - for prefix, server in self._mounted_servers.items(): - try: - if server.match_prompt(name): - prompt_name = server.strip_prompt_prefix(name) - return await server.server._mcp_get_prompt( - prompt_name, arguments - ) - except Exception as e: - logger.warning( - f"Failed to get prompt from mounted server '{prefix}': {e}" - ) - continue + 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) raise NotFoundError(f"Unknown prompt: {name}")