Repository: jlowin/fastmcp

Issue:
Handling Server Failures in FastMCP Client for Seamless Tool Retrieval
### Enhancement Description

**requirements.txt**
```
fastmcp==2.5.1
mcp==1.9.1
```

**server.py**
```
import logging

from fastmcp import FastMCP

mcp = FastMCP(
    name="ConfiguredServer",
)

@mcp.tool()
def add(a: int, b: int) -> int:
    """Add two numbers"""
    logging.info(f"Adding {a} + {b}")
    return a + b

if __name__ == "__main__":
    mcp.run(transport="sse", host="localhost", port="8002")
```

**client.py**
```
import asyncio

from fastmcp.client import Client

config = {
    "mcpServers": {
        "math": {
            "url": "http://localhost:8002/sse",
            "transport": "sse"
        },
        "multiply": {
            "url": "http://localhost:8003/sse", # server is down or not able to authenticate
            "transport": "sse"
        }
    }
}

async def main():
    client = Client(config)
    # Connect to the server using SSE
    async with client:
        tools = [] 
        try:
            # List available tools
            tools_result = await client.list_tools()
            print(f"Available tools: {tools_result}")
            tools.extend(tools_result)
        except Exception as err:
            print(f"Error loading tools {err}")
        # Call our calculator tool
        breakpoint()
        result = await client.call_tool("add", {"a": 5, "b": 3})
        print(f"Result: {result[0].text}")

if __name__ == "__main__":
    asyncio.run(main())
```

**Problem Statement**
When using fastmcp.Client with a configuration for multiple servers, if one or more of those servers are unavailable (e.g., down or fail authentication during initial connection), the client.list_tools() method currently returns an empty list. 

**Desired Behavior**
Retrieve tools only from the active and successfully connected servers, `ignoring silently` with logging those that are unreachable or unauthenticated."

### Use Case
**Analysis of the Problem**
When using fastmcp.Client with MCPConfigTransport for multiple servers, if one server is down (e.g., "multiply"), client.list_tools() returns an empty list. This occurs because MCPConfigTransport likely attempts to connect to all servers simultaneously, and a single failure (e.g., connection or authentication error) causes the entire operation to fail, preventing tool retrieval from operational servers.

Below is the working code with error handling. I believe the base fastmcp library should natively handle server failures silently, logging errors, and returning tools from operational servers.

**client.py**
```
import asyncio

from fastmcp.client import Client
from fastmcp.client.transports import SSETransport

# Configuration with multiple servers
config = {
    "mcpServers": {
        "math": {
            "url": "http://localhost:8003/sse",
            "transport": "sse"
        },
        "multiply": {
            "url": "http://localhost:8004/sse",  # This server is down or server restart or not able to authenticate (in short: server failure)
            "transport": "sse"
        }
    }
}


async def main():
    # Create separate clients for each server
    clients = {}
    for server_name, server_config in config["mcpServers"].items():
        transport = SSETransport(server_config["url"])
        client = Client(transport=transport)
        clients[server_name] = client

    # Collect tools from all servers that are up
    all_tools = []

    for server_name, client in clients.items():
        try:
            async with client:
                tools = await client.list_tools()
                # Prefix tool names with server name to avoid conflicts
                prefixed_tools = [
                    {**tool, "name": f"{server_name}.{tool['name']}"} for tool in tools]
                all_tools.extend(prefixed_tools)
                print(
                    f"Tools from {server_name}: {[tool['name'] for tool in prefixed_tools]}")
        except Exception as err:
            print(f"Error loading tools from {server_name}: {err}")

    # Call a specific tool (e.g., math.add) if available
    tool_name = "math.add"
    if tool_name in [tool.name for tool in all_tools]:
        server_name = tool_name.split(".")[0]  # Extract server name (math)
        client = clients[tool_name]
        async with client:
            try:
                result = await client.call_tool("add", {"a": 5, "b": 3})
                print(f"Result from {tool_name}: {result[0].text}")
            except Exception as err:
                print(f"Error calling {tool_name}: {err}")
    else:
        print(f"{tool_name} not available")

if __name__ == "__main__":
    asyncio.run(main())

```

**Proposed Solution**
Create separate Client instances with SSETransport for each server. Independently list tools from each, prefixing names to avoid conflicts. Handle connection failures individually to collect tools from up servers. 
Call tools using the corresponding client, ensuring functionality despite downed servers, with robust error handling.



Discussion:
I agree, that's a good idea. Under the hood we are creating separate clients so I think this is relatively straightforward, and would actually be implemented at the core FastMCP server level when dealing with mounted subservers.



Patch:
diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py
index 89d76037..f5e35916 100644
--- a/src/fastmcp/server/server.py
+++ b/src/fastmcp/server/server.py
@@ -257,9 +257,15 @@ async def get_tools(self) -> dict[str, Tool]:
         """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 @@ async def get_resources(self) -> dict[str, Resource]:
         """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 @@ async def get_resource_templates(self) -> dict[str, ResourceTemplate]:
             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 @@ async def get_prompts(self) -> dict[str, Prompt]:
         """
         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


Test patch:
diff --git a/tests/server/test_mount.py b/tests/server/test_mount.py
index 598cf3aa..a04a14ed 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 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 @@ def second_tool() -> str:
         # 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."""