Merge pull request #605 from davenpi/fix-unreachable-mounted-servers

Add graceful error handling for unreachable mounted servers
This commit is contained in:
Jeremiah Lowin 2025-05-28 16:07:30 -04:00 committed by GitHub
commit c4d941f671
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 113 additions and 13 deletions

View file

@ -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

View file

@ -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."""