diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py index e42bcab681..1323aba041 100644 --- a/src/fastmcp/server/server.py +++ b/src/fastmcp/server/server.py @@ -713,6 +713,9 @@ async def get_tool(self, key: str) -> Tool: return tool except NotFoundError: continue + except RuntimeError as e: + logger.warning(f"Provider unavailable when getting tool {key!r}: {e}") + continue raise NotFoundError(f"Unknown tool: {key}") @@ -754,6 +757,12 @@ async def _get_resource_or_template_or_none( return resource except NotFoundError: continue + except RuntimeError as e: + # Connection failures (e.g., dead proxy) - continue to next provider + logger.warning( + f"Provider unavailable when getting resource {uri!r}: {e}" + ) + continue # Check provider templates for provider in self._providers: @@ -763,6 +772,12 @@ async def _get_resource_or_template_or_none( return template except NotFoundError: continue + except RuntimeError as e: + # Connection failures (e.g., dead proxy) - continue to next provider + logger.warning( + f"Provider unavailable when getting resource template {uri!r}: {e}" + ) + continue return None @@ -803,6 +818,12 @@ async def get_resource(self, key: str) -> Resource: return resource except NotFoundError: continue + except RuntimeError as e: + # Connection failures (e.g., dead proxy) - continue to next provider + logger.warning( + f"Provider unavailable when getting resource {key!r}: {e}" + ) + continue raise NotFoundError(f"Unknown resource: {key}") @@ -843,6 +864,12 @@ async def get_resource_template(self, key: str) -> ResourceTemplate: return template except NotFoundError: continue + except RuntimeError as e: + # Connection failures (e.g., dead proxy) - continue to next provider + logger.warning( + f"Provider unavailable when getting resource template {key!r}: {e}" + ) + continue raise NotFoundError(f"Unknown resource template: {key}") @@ -883,6 +910,10 @@ async def get_prompt(self, key: str) -> Prompt: return prompt except NotFoundError: continue + except RuntimeError as e: + # Connection failures (e.g., dead proxy) - continue to next provider + logger.warning(f"Provider unavailable when getting prompt {key!r}: {e}") + continue raise NotFoundError(f"Unknown prompt: {key}") @@ -1531,6 +1562,10 @@ async def _call_tool( except ToolError: logger.exception(f"Error calling tool {tool_name!r}") raise + except RuntimeError as e: + # Connection failures (e.g., dead proxy) - continue to next provider + logger.warning(f"Provider unavailable for tool {tool_name!r}: {e}") + continue except Exception as e: logger.exception(f"Error calling tool {tool_name!r} from provider") if self._mask_error_details: @@ -1643,6 +1678,10 @@ async def _read_resource( except ResourceError: logger.exception(f"Error reading resource {uri_str!r}") raise + except RuntimeError as e: + # Connection failures (e.g., dead proxy) - continue to next provider + logger.warning(f"Provider unavailable for resource {uri_str!r}: {e}") + continue except Exception as e: logger.exception(f"Error reading resource {uri_str!r} from provider") if self._mask_error_details: @@ -1660,6 +1699,12 @@ async def _read_resource( except ResourceError: logger.exception(f"Error reading resource {uri_str!r}") raise + except RuntimeError as e: + # Connection failures (e.g., dead proxy) - continue to next provider + logger.warning( + f"Provider unavailable for resource template {uri_str!r}: {e}" + ) + continue except Exception as e: logger.exception( f"Error reading resource {uri_str!r} from provider template" @@ -1767,6 +1812,10 @@ async def _get_prompt( except PromptError: logger.exception(f"Error rendering prompt {name!r}") raise + except RuntimeError as e: + # Connection failures (e.g., dead proxy) - continue to next provider + logger.warning(f"Provider unavailable for prompt {name!r}: {e}") + continue except Exception as e: logger.exception(f"Error rendering prompt {name!r} from provider") if self._mask_error_details: diff --git a/tests/server/test_proxy_shared_prefix.py b/tests/server/test_proxy_shared_prefix.py new file mode 100644 index 0000000000..f762043a24 --- /dev/null +++ b/tests/server/test_proxy_shared_prefix.py @@ -0,0 +1,239 @@ +"""Test dead proxy with shared prefix scenarios. + +These tests verify that when a dead proxy and a working server share the same +prefix (or have no prefix), the system gracefully falls back to the working +server instead of failing. +""" + +from typing import cast + +import httpx +from mcp.shared._httpx_utils import McpHttpClientFactory + +from fastmcp import Client, FastMCP +from fastmcp.client.transports import SSETransport + + +def _short_timeout_client_factory(**kwargs) -> httpx.AsyncClient: # type: ignore[type-arg] + """Create httpx client with short timeout for faster test failures.""" + return httpx.AsyncClient(timeout=httpx.Timeout(1.0), **kwargs) + + +# Cast to satisfy type checker +_factory = cast(McpHttpClientFactory, _short_timeout_client_factory) + + +class TestDeadProxySharedPrefix: + """Test graceful handling when dead proxy shares prefix with working server.""" + + async def test_dead_proxy_first_same_prefix_tools(self): + """Test tool call when dead proxy is mounted FIRST with same prefix.""" + main_app = FastMCP("MainApp") + working_app = FastMCP("WorkingApp") + + @working_app.tool + def my_tool() -> str: + return "Working tool" + + # Mount unreachable proxy FIRST with prefix "shared" + # Use short timeout to fail fast on Windows + unreachable_client = Client( + transport=SSETransport( + "http://127.0.0.1:9999/sse/", + httpx_client_factory=_factory, + ), + name="unreachable_client", + ) + unreachable_proxy = FastMCP.as_proxy( + unreachable_client, name="unreachable_proxy" + ) + main_app.mount(unreachable_proxy, "shared") + + # Mount working server SECOND with SAME prefix "shared" + main_app.mount(working_app, "shared") + + async with Client(main_app) as client: + # List should work (errors caught) + tools = await client.list_tools() + tool_names = [t.name for t in tools] + assert "shared_my_tool" in tool_names + + # Call the tool - should fall back to working server + result = await client.call_tool("shared_my_tool", {}) + assert result.data == "Working tool" + + async def test_dead_proxy_first_no_prefix_tools(self): + """Test tool call when dead proxy is mounted FIRST with no prefix.""" + main_app = FastMCP("MainApp") + working_app = FastMCP("WorkingApp") + + @working_app.tool + def my_tool() -> str: + return "Working tool" + + # Mount unreachable proxy FIRST without prefix + # Use short timeout to fail fast on Windows + unreachable_client = Client( + transport=SSETransport( + "http://127.0.0.1:9999/sse/", + httpx_client_factory=_factory, + ), + name="unreachable_client", + ) + unreachable_proxy = FastMCP.as_proxy( + unreachable_client, name="unreachable_proxy" + ) + main_app.mount(unreachable_proxy) + + # Mount working server SECOND without prefix + main_app.mount(working_app) + + async with Client(main_app) as client: + # List should work (errors caught) + tools = await client.list_tools() + tool_names = [t.name for t in tools] + assert "my_tool" in tool_names + + # Call the tool - should fall back to working server + result = await client.call_tool("my_tool", {}) + assert result.data == "Working tool" + + async def test_dead_proxy_first_same_prefix_resources(self): + """Test resource read when dead proxy is mounted FIRST with same prefix.""" + main_app = FastMCP("MainApp") + working_app = FastMCP("WorkingApp") + + @working_app.resource(uri="data://info") + def my_resource() -> str: + return "Working resource" + + # Mount unreachable proxy FIRST + unreachable_client = Client( + transport=SSETransport( + "http://127.0.0.1:9999/sse/", + httpx_client_factory=_factory, + ), + name="unreachable_client", + ) + unreachable_proxy = FastMCP.as_proxy( + unreachable_client, name="unreachable_proxy" + ) + main_app.mount(unreachable_proxy, "shared") + + # Mount working server SECOND with same prefix + main_app.mount(working_app, "shared") + + async with Client(main_app) as client: + # List should work + resources = await client.list_resources() + resource_uris = [str(r.uri) for r in resources] + assert "data://shared/info" in resource_uris + + # Read resource - should fall back to working server + result = await client.read_resource("data://shared/info") + assert result[0].text == "Working resource" + + async def test_dead_proxy_first_same_prefix_prompts(self): + """Test prompt render when dead proxy is mounted FIRST with same prefix.""" + main_app = FastMCP("MainApp") + working_app = FastMCP("WorkingApp") + + @working_app.prompt + def my_prompt() -> str: + return "Working prompt" + + # Mount unreachable proxy FIRST + unreachable_client = Client( + transport=SSETransport( + "http://127.0.0.1:9999/sse/", + httpx_client_factory=_factory, + ), + name="unreachable_client", + ) + unreachable_proxy = FastMCP.as_proxy( + unreachable_client, name="unreachable_proxy" + ) + main_app.mount(unreachable_proxy, "shared") + + # Mount working server SECOND with same prefix + main_app.mount(working_app, "shared") + + async with Client(main_app) as client: + # List should work + prompts = await client.list_prompts() + prompt_names = [p.name for p in prompts] + assert "shared_my_prompt" in prompt_names + + # Get prompt - should fall back to working server + result = await client.get_prompt("shared_my_prompt") + assert result.messages[0].content.text == "Working prompt" + + async def test_dead_proxy_first_no_prefix_resources(self): + """Test resource read when dead proxy is mounted FIRST with no prefix.""" + main_app = FastMCP("MainApp") + working_app = FastMCP("WorkingApp") + + @working_app.resource(uri="data://info") + def my_resource() -> str: + return "Working resource" + + # Mount unreachable proxy FIRST without prefix + unreachable_client = Client( + transport=SSETransport( + "http://127.0.0.1:9999/sse/", + httpx_client_factory=_factory, + ), + name="unreachable_client", + ) + unreachable_proxy = FastMCP.as_proxy( + unreachable_client, name="unreachable_proxy" + ) + main_app.mount(unreachable_proxy) + + # Mount working server SECOND without prefix + main_app.mount(working_app) + + async with Client(main_app) as client: + # List should work + resources = await client.list_resources() + resource_uris = [str(r.uri) for r in resources] + assert "data://info" in resource_uris + + # Read resource - should fall back to working server + result = await client.read_resource("data://info") + assert result[0].text == "Working resource" + + async def test_dead_proxy_first_no_prefix_prompts(self): + """Test prompt render when dead proxy is mounted FIRST with no prefix.""" + main_app = FastMCP("MainApp") + working_app = FastMCP("WorkingApp") + + @working_app.prompt + def my_prompt() -> str: + return "Working prompt" + + # Mount unreachable proxy FIRST without prefix + unreachable_client = Client( + transport=SSETransport( + "http://127.0.0.1:9999/sse/", + httpx_client_factory=_factory, + ), + name="unreachable_client", + ) + unreachable_proxy = FastMCP.as_proxy( + unreachable_client, name="unreachable_proxy" + ) + main_app.mount(unreachable_proxy) + + # Mount working server SECOND without prefix + main_app.mount(working_app) + + async with Client(main_app) as client: + # List should work + prompts = await client.list_prompts() + prompt_names = [p.name for p in prompts] + assert "my_prompt" in prompt_names + + # Get prompt - should fall back to working server + result = await client.get_prompt("my_prompt") + assert result.messages[0].content.text == "Working prompt"